From fd5b0560ce7187ba09ffb54b20b70ef72bde97bf Mon Sep 17 00:00:00 2001 From: lzh <526494747@qq.com> Date: Tue, 22 Sep 2026 10:06:54 +0800 Subject: [PATCH 001/126] feat(voice): add Tencent Cloud realtime (streaming) speech recognition Dictate with live partial results: the composer mic streams 16 kHz PCM over a WebSocket to Octop, which signs and proxies the audio to Tencent's realtime ASR V2 endpoint. Credentials never leave the server; the browser only learns a `stt_realtime` boolean from `/api/voice/active`. - settings: add a "realtime speech recognition" switch plus an AppID field to the Tencent Cloud voice drawer. Both live in `voice_providers.extra_json`, so no schema migration is needed. - interaction: with realtime on, the mic becomes press-and-hold (start on pointerdown, finish on release/leave/cancel) and stays available while a reply is streaming, so dictation can be queued. With the switch off the previous click-to-record one-shot upload path is unchanged. - protocol: realtime ASR V2 answers with `result` (`slice_type`, `index`, `voice_text_str`) while the docs describe `sentences`; both are normalized in `infra/voice/realtime.py`. - errors: new `VOICE_REALTIME_UNAVAILABLE` code plus localized copy for the Tencent realtime error codes (4002-4009, 5000-5002). Verified end-to-end against Tencent with real speech (edge-tts -> 16 kHz PCM over the live WebSocket) and by `make all`. --- CHANGELOG.md | 1 + dashboard/src/api/modules/voice.ts | 2 + dashboard/src/hooks/useVoiceConfig.ts | 12 +- dashboard/src/hooks/useVoiceInput.ts | 173 ++++++++++++- dashboard/src/locales/en.json | 7 + dashboard/src/locales/zh.json | 7 + .../components/ChatInput.prefill.test.tsx | 3 + .../src/pages/Chat/components/ChatInput.tsx | 28 ++- .../components/ChatInputActionsRow.test.tsx | 92 +++++++ .../Chat/components/ChatInputActionsRow.tsx | 51 +++- dashboard/src/pages/Settings/Voice/index.tsx | 46 +++- dashboard/src/utils/pcmCapture.test.ts | 71 ++++++ dashboard/src/utils/pcmCapture.ts | 71 ++++++ dashboard/src/utils/realtimeStt.test.ts | 187 ++++++++++++++ dashboard/src/utils/realtimeStt.ts | 175 +++++++++++++ src/octop/api/routers/voice.py | 196 ++++++++++++++- src/octop/i18n/domains/voice.py | 25 +- src/octop/i18n/en.json | 13 + src/octop/i18n/zh.json | 13 + src/octop/infra/errors.py | 2 + src/octop/infra/utils/tencent_asr_sign.py | 70 ++++++ src/octop/infra/voice/adapters.py | 8 +- src/octop/infra/voice/manager.py | 31 ++- src/octop/infra/voice/realtime.py | 217 ++++++++++++++++ tests/integration/test_voice_stt_stream_ws.py | 196 +++++++++++++++ tests/support/http.py | 3 + tests/unit/i18n/test_voice.py | 22 +- tests/unit/test_voice_manager.py | 87 +++++++ tests/unit/test_voice_realtime.py | 231 ++++++++++++++++++ tests/unit/utils/test_tencent_asr_sign.py | 84 +++++++ 30 files changed, 2090 insertions(+), 34 deletions(-) create mode 100644 dashboard/src/utils/pcmCapture.test.ts create mode 100644 dashboard/src/utils/pcmCapture.ts create mode 100644 dashboard/src/utils/realtimeStt.test.ts create mode 100644 dashboard/src/utils/realtimeStt.ts create mode 100644 src/octop/infra/utils/tencent_asr_sign.py create mode 100644 src/octop/infra/voice/realtime.py create mode 100644 tests/integration/test_voice_stt_stream_ws.py create mode 100644 tests/unit/test_voice_realtime.py create mode 100644 tests/unit/utils/test_tencent_asr_sign.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d01e44d13d..710dec0f3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - 登录验证码新增极验行为验 v4(#870):设置页配置 captcha_id / captcha_key,登录弹窗完成验证,服务端按官方协议 HMAC-SHA256 签名后到 gcaptcha4 二次校验(form-urlencoded,仅 result=success 放行) - 对话支持默认折叠思考与工具过程(浏览器本地偏好)(#718) - 对话中可隐藏不常用的共享专家(浏览器本地偏好)(#589) +- 腾讯云语音新增实时(流式)识别:开启后输入框麦克风为按住说话,边说边显示中间结果、松开即出全文;凭据仅保存在服务端,浏览器只收到开关状态;配置存于语音 provider 的 extra_json,无数据库迁移 ### 修复 diff --git a/dashboard/src/api/modules/voice.ts b/dashboard/src/api/modules/voice.ts index 8562a6414a..530c0b83cb 100644 --- a/dashboard/src/api/modules/voice.ts +++ b/dashboard/src/api/modules/voice.ts @@ -26,6 +26,8 @@ export interface VoiceProviderRow { export interface ActiveVoice { stt: string; tts: string; + /** True when the active STT provider streams results over WebSocket. */ + stt_realtime?: boolean; } export interface VoiceProviderInput { diff --git a/dashboard/src/hooks/useVoiceConfig.ts b/dashboard/src/hooks/useVoiceConfig.ts index 78c315b06b..b7fec1d29d 100644 --- a/dashboard/src/hooks/useVoiceConfig.ts +++ b/dashboard/src/hooks/useVoiceConfig.ts @@ -30,10 +30,14 @@ export function prefetchVoiceConfig(): void { } export function useVoiceConfig() { - const [active, setActive] = useState({ - stt: "browser", - tts: "browser", - }); + const [active, setActive] = useState( + () => + cachedActiveVoice() ?? { + stt: "browser", + tts: "browser", + stt_realtime: false, + }, + ); const [loading, setLoading] = useState(true); const refresh = useCallback(async () => { diff --git a/dashboard/src/hooks/useVoiceInput.ts b/dashboard/src/hooks/useVoiceInput.ts index 76d2614d58..29ad1f84b8 100644 --- a/dashboard/src/hooks/useVoiceInput.ts +++ b/dashboard/src/hooks/useVoiceInput.ts @@ -1,8 +1,16 @@ import { useCallback, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { getWsUrl } from "../api/config"; import { voiceApi, type ActiveVoice } from "../api/modules/voice"; +import { getAuthToken } from "../api/request"; import { speechLocaleFromUi } from "../utils/localePrefs"; -import { cachedActiveVoice, fetchActiveVoice } from "./useVoiceConfig"; +import { PcmFrameBuffer, downsampleToPcm16 } from "../utils/pcmCapture"; +import { RealtimeSttClient } from "../utils/realtimeStt"; +import { + cachedActiveVoice, + fetchActiveVoice, + useVoiceConfig, +} from "./useVoiceConfig"; import { message as antMessage } from "@/utils/antdMessage"; @@ -237,18 +245,147 @@ export function isSttAvailable(): boolean { return browserSttAvailable(); // browser STT (Chrome / Edge) } -export function useVoiceInput(onText: (text: string) => void) { +export interface VoiceInputOptions { + /** Called when a capture session starts, before any transcript arrives. */ + onStart?: () => void; + /** Cumulative transcript while the session is still being refined. */ + onInterim?: (text: string) => void; + /** Cumulative transcript of the session; the realtime path calls it per sentence. */ + onText: (text: string) => void; +} + +/** Live 16 kHz capture feeding the realtime STT WebSocket. */ +interface RealtimeCapture { + client: RealtimeSttClient; + stream: MediaStream; + ctx: AudioContext; + source: MediaStreamAudioSourceNode; + processor: ScriptProcessorNode; + sink: GainNode; +} + +function realtimeSttUrl(language: string): string { + const params = new URLSearchParams({ + locale: language.toLowerCase().startsWith("zh") ? "zh" : "en", + }); + const token = getAuthToken(); + if (token) params.set("token", token); + return `${getWsUrl("/voice/stt-stream")}?${params.toString()}`; +} + +function audioContextCtor(): typeof AudioContext | null { + return ( + window.AudioContext ?? + (window as Window & { webkitAudioContext?: typeof AudioContext }) + .webkitAudioContext ?? + null + ); +} + +export function useVoiceInput(options: VoiceInputOptions) { const { t, i18n } = useTranslation(); const [recording, setRecording] = useState(false); const [transcribing, setTranscribing] = useState(false); const mediaRecorderRef = useRef(null); const chunksRef = useRef([]); + const captureRef = useRef(null); + // A press-and-hold release can land while `startRealtime` is still awaiting + // getUserMedia; remember it so the capture is not left running. + const pendingStopRef = useRef(false); + // Keep the latest callbacks without rebuilding every capture closure. + const optionsRef = useRef(options); + optionsRef.current = options; + const { active } = useVoiceConfig(); // Follow dashboard UI locale — not navigator.language (often en-US on // machines used with Chinese UI / Chinese speech). const language = speechLocaleFromUi(i18n.language); + const teardownRealtime = useCallback(() => { + const capture = captureRef.current; + captureRef.current = null; + if (!capture) return; + capture.processor.onaudioprocess = null; + capture.processor.disconnect(); + capture.source.disconnect(); + capture.sink.disconnect(); + capture.stream.getTracks().forEach((track) => track.stop()); + void capture.ctx.close(); + }, []); + + const finishRealtime = useCallback(() => { + teardownRealtime(); + setRecording(false); + setTranscribing(false); + }, [teardownRealtime]); + + const startRealtime = useCallback(async () => { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + echoCancellation: true, + noiseSuppression: true, + }, + }); + const Ctor = audioContextCtor(); + if (!Ctor) throw new Error("AudioContext unavailable"); + const ctx = new Ctor(); + const source = ctx.createMediaStreamSource(stream); + const processor = ctx.createScriptProcessor(1024, 1, 1); + const sink = ctx.createGain(); + sink.gain.value = 0; // keep the graph running without echoing the mic + + const client = new RealtimeSttClient(realtimeSttUrl(language), { + onInterim: (text) => optionsRef.current.onInterim?.(text), + onFinal: (text) => { + if (text) optionsRef.current.onText(text); + }, + onDone: finishRealtime, + onError: (message) => { + finishRealtime(); + antMessage.error(message || t("voice.realtimeFailed")); + }, + }); + + captureRef.current = { client, stream, ctx, source, processor, sink }; + const frames = new PcmFrameBuffer(); + processor.onaudioprocess = (event) => { + const pcm = downsampleToPcm16( + event.inputBuffer.getChannelData(0), + ctx.sampleRate, + ); + for (const frame of frames.push(pcm)) client.send(frame); + }; + source.connect(processor); + processor.connect(sink); + sink.connect(ctx.destination); + + // Frames are buffered until the server reports ready; failures surface + // through `onError`, so the connect rejection needs no extra handling. + void client.connect().catch(() => undefined); + setRecording(true); + }, [finishRealtime, language, t]); + + const stopRealtime = useCallback(() => { + const capture = captureRef.current; + if (!capture) return; + capture.processor.onaudioprocess = null; + capture.processor.disconnect(); + capture.source.disconnect(); + capture.sink.disconnect(); + capture.stream.getTracks().forEach((track) => track.stop()); + setRecording(false); + setTranscribing(true); + // `end` makes the server flush its final sentences, then `onDone` clears. + capture.client.end(); + }, []); + const stopRecording = useCallback(async () => { + pendingStopRef.current = true; + if (captureRef.current) { + stopRealtime(); + return; + } const recorder = mediaRecorderRef.current; if (!recorder || recorder.state === "inactive") { setRecording(false); @@ -280,7 +417,7 @@ export function useVoiceInput(onText: (text: string) => void) { const upload = await recordedBlobToWav(blob); const result = await voiceApi.transcribe(upload, language); const text = result.text?.trim() ?? ""; - if (text) onText(text); + if (text) optionsRef.current.onText(text); else antMessage.info(t("voice.sttEmpty")); } catch (err) { const msg = err instanceof Error ? err.message : ""; @@ -292,18 +429,32 @@ export function useVoiceInput(onText: (text: string) => void) { } finally { setTranscribing(false); } - }, [onText, t, language]); + }, [t, language, stopRealtime]); const startRecording = useCallback(async () => { // Read cached config synchronously to stay in the user-gesture stack. const active: ActiveVoice | null = cachedActiveVoice(); + pendingStopRef.current = false; + optionsRef.current.onStart?.(); + + if (active?.stt_realtime && canRecordAudio() && audioContextCtor()) { + // Realtime STT: stream PCM and let results arrive while speaking. + try { + await startRealtime(); + } catch { + antMessage.error(t("voice.micDenied")); + return; + } + if (pendingStopRef.current) stopRealtime(); + return; + } if ((active?.stt ?? "browser") === "browser" && browserSttAvailable()) { // Browser STT: use the native SpeechRecognition API directly. setTranscribing(true); try { const text = await transcribeWithBrowser(language); - if (text) onText(text); + if (text) optionsRef.current.onText(text); else antMessage.info(t("voice.sttEmpty")); } catch (err) { const msg = err instanceof Error ? err.message : ""; @@ -343,7 +494,7 @@ export function useVoiceInput(onText: (text: string) => void) { } catch { antMessage.error(t("voice.micDenied")); } - }, [onText, t, language]); + }, [t, language, startRealtime, stopRealtime]); const toggle = useCallback(() => { if (transcribing) return; @@ -351,5 +502,13 @@ export function useVoiceInput(onText: (text: string) => void) { else void startRecording(); }, [recording, transcribing, startRecording, stopRecording]); - return { recording, transcribing, toggle }; + return { + recording, + transcribing, + /** True when the mic streams results live (press and hold, not click). */ + realtime: active.stt_realtime === true, + start: startRecording, + stop: stopRecording, + toggle, + }; } diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 6a95769ae6..aceacc29f1 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -132,6 +132,7 @@ "VOICE_KIND_UNSUPPORTED": "Unsupported voice provider.", "VOICE_CAPABILITY_MISMATCH": "Voice capability does not match.", "VOICE_PROVIDER_DISABLED": "Voice provider is disabled.", + "VOICE_REALTIME_UNAVAILABLE": "Realtime speech recognition is unavailable. Check the Tencent Cloud voice settings.", "INTERNAL_ERROR": "Internal server error.", "TLS_NOT_ELIGIBLE": "TLS issuance is not available in the current server configuration.", "TLS_ISSUE_IN_PROGRESS": "A certificate issuance task is already running.", @@ -2626,6 +2627,12 @@ "probeSuccess": "Voice model probe succeeded", "probeFailed": "Voice model probe failed", "tencentHint": "Create SecretId / SecretKey in Tencent Cloud console. ASR and TTS include free trial quota.", + "realtimeStt": "Realtime speech recognition", + "realtimeSttHint": "When enabled, voice input in the composer streams text as you speak (AppID required).", + "realtimeAppIdRequired": "AppID is required to enable realtime speech recognition.", + "realtimeFailed": "Realtime speech recognition failed", + "holdToTalk": "Hold to talk", + "releaseToStop": "Release to finish", "openaiHint": "Uses OpenAI Whisper for STT and OpenAI TTS for read-aloud.", "mimoHint": "Xiaomi MiMo ASR/TTS. Choose endpoint and enter the corresponding API key.", "mimoEndpoint": "API Endpoint", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 063c061611..b9cb0edcc5 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -132,6 +132,7 @@ "VOICE_KIND_UNSUPPORTED": "不支持的语音提供商。", "VOICE_CAPABILITY_MISMATCH": "语音能力不匹配。", "VOICE_PROVIDER_DISABLED": "语音提供商已禁用。", + "VOICE_REALTIME_UNAVAILABLE": "实时语音识别不可用,请检查腾讯云语音配置。", "INTERNAL_ERROR": "服务器内部错误。", "TLS_NOT_ELIGIBLE": "当前服务器配置不支持 TLS 证书申请。", "TLS_ISSUE_IN_PROGRESS": "已有证书申请任务正在进行。", @@ -2626,6 +2627,12 @@ "probeSuccess": "语音模型探测成功", "probeFailed": "语音模型探测失败", "tencentHint": "在腾讯云控制台创建 SecretId / SecretKey,语音识别与合成均有免费试用额度。", + "realtimeStt": "实时语音识别", + "realtimeSttHint": "开启后,聊天输入框的语音输入将边说边出字(需要 AppID)。", + "realtimeAppIdRequired": "开启实时语音识别需要填写 AppID。", + "realtimeFailed": "实时语音识别失败", + "holdToTalk": "按住说话", + "releaseToStop": "松开结束", "openaiHint": "使用 OpenAI Whisper 识别、OpenAI TTS 朗读。", "mimoHint": "小米 Mimo 语音识别与合成。选择接入点并填写对应的 API Key。", "mimoEndpoint": "API 接入点", diff --git a/dashboard/src/pages/Chat/components/ChatInput.prefill.test.tsx b/dashboard/src/pages/Chat/components/ChatInput.prefill.test.tsx index a06d82df9d..94bf7fd9f7 100644 --- a/dashboard/src/pages/Chat/components/ChatInput.prefill.test.tsx +++ b/dashboard/src/pages/Chat/components/ChatInput.prefill.test.tsx @@ -17,6 +17,9 @@ vi.mock("../../../hooks/useVoiceInput", () => ({ useVoiceInput: () => ({ recording: false, transcribing: false, + realtime: false, + start: vi.fn(), + stop: vi.fn(), toggle: vi.fn(), }), })); diff --git a/dashboard/src/pages/Chat/components/ChatInput.tsx b/dashboard/src/pages/Chat/components/ChatInput.tsx index 79384fc466..da5eb51942 100644 --- a/dashboard/src/pages/Chat/components/ChatInput.tsx +++ b/dashboard/src/pages/Chat/components/ChatInput.tsx @@ -205,9 +205,19 @@ const ChatInput = forwardRef( setText(value); }, []); - const handleVoiceText = useCallback( + const textRef = useRef(text); + useEffect(() => { + textRef.current = text; + }, [text]); + // Text that existed before the current voice session started; the session's + // (possibly still refining) transcript is appended to it on every update. + const voiceBaselineRef = useRef(""); + + const applyVoiceText = useCallback( (spoken: string) => { - setText((prev) => (prev.trim() ? `${prev.trim()} ${spoken}` : spoken)); + const base = voiceBaselineRef.current.trim(); + const next = spoken.trim(); + setText(base ? (next ? `${base} ${next}` : base) : next); userHasEditedRef.current = true; onUserInput?.(); }, @@ -216,8 +226,17 @@ const ChatInput = forwardRef( const { recording, transcribing, + realtime: realtimeVoice, + start: startVoice, + stop: stopVoice, toggle: toggleVoice, - } = useVoiceInput(handleVoiceText); + } = useVoiceInput({ + onStart: () => { + voiceBaselineRef.current = textRef.current; + }, + onInterim: applyVoiceText, + onText: applyVoiceText, + }); const textareaRef = useRef(null); const { attachments, @@ -849,6 +868,9 @@ const ChatInput = forwardRef( uploading={uploading} recording={recording} transcribing={transcribing} + voiceHoldToTalk={realtimeVoice} + onVoiceStart={() => void startVoice()} + onVoiceStop={() => void stopVoice()} browserRecording={browserRecording} browserReplayBusy={browserReplayBusy} browserLastRecordingId={browserLastRecordingId} diff --git a/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx b/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx index a63436dd76..5470844f78 100644 --- a/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx +++ b/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx @@ -4,6 +4,17 @@ import { describe, expect, it, vi } from "vitest"; import type { ResolvedModel } from "../../../api/types"; import ChatInputActionsRow from "./ChatInputActionsRow"; +// `isSttAvailable()` runs at module load, so the browser APIs it probes must +// exist before this module graph is imported. +vi.hoisted(() => { + vi.stubGlobal("MediaRecorder", class {}); + Object.defineProperty(navigator, "mediaDevices", { + value: { getUserMedia: () => Promise.resolve() }, + configurable: true, + }); + return true; +}); + vi.mock("./ContextWindowRing", () => ({ default: () => null, })); @@ -66,3 +77,84 @@ describe("ChatInputActionsRow compact pickers", () => { expect(document.querySelector(".ant-drawer-content")).toBeNull(); }); }); + +function renderRow(overrides: Record = {}) { + const handlers = { + onVoiceStart: vi.fn(), + onVoiceStop: vi.fn(), + onToggleVoice: vi.fn(), + }; + const view = render( + + + , + ); + const mic = view.container + .querySelector("svg.lucide-mic") + ?.closest("button") as HTMLButtonElement; + return { ...view, ...handlers, mic }; +} + +describe("ChatInputActionsRow voice button", () => { + it("dictates while held down when realtime STT is on", () => { + const { mic, onVoiceStart, onVoiceStop, onToggleVoice } = renderRow({ + voiceHoldToTalk: true, + }); + + fireEvent.pointerDown(mic); + expect(onVoiceStart).toHaveBeenCalledTimes(1); + + fireEvent.pointerUp(mic); + expect(onVoiceStop).toHaveBeenCalledTimes(1); + + // A plain click must not toggle the recorder in hold-to-talk mode. + fireEvent.click(mic); + expect(onToggleVoice).not.toHaveBeenCalled(); + expect(onVoiceStart).toHaveBeenCalledTimes(1); + }); + + it("finishes when the pointer slides off the button", () => { + const { mic, onVoiceStop } = renderRow({ voiceHoldToTalk: true }); + + fireEvent.pointerDown(mic); + fireEvent.pointerLeave(mic); + fireEvent.pointerUp(mic); + + expect(onVoiceStop).toHaveBeenCalledTimes(1); + }); + + it("keeps click-to-toggle when realtime STT is off", () => { + const { mic, onToggleVoice, onVoiceStart } = renderRow(); + + fireEvent.click(mic); + expect(onToggleVoice).toHaveBeenCalledTimes(1); + + fireEvent.pointerDown(mic); + expect(onVoiceStart).not.toHaveBeenCalled(); + }); + + it("stays available while a reply is still streaming", () => { + const { mic } = renderRow({ voiceHoldToTalk: true, isStreaming: true }); + + expect(mic).not.toBeDisabled(); + }); +}); diff --git a/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx b/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx index bcfa05e1c0..755453e32f 100644 --- a/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx +++ b/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx @@ -96,6 +96,10 @@ interface ChatInputActionsRowProps { uploading: boolean; recording: boolean; transcribing: boolean; + /** Realtime STT streams while held down, so the mic is press-and-hold. */ + voiceHoldToTalk?: boolean; + onVoiceStart?: () => void; + onVoiceStop?: () => void; browserRecording?: boolean; browserReplayBusy?: boolean; browserLastRecordingId?: string | null; @@ -157,6 +161,9 @@ export default function ChatInputActionsRow({ uploading, recording, transcribing, + voiceHoldToTalk = false, + onVoiceStart, + onVoiceStop, browserRecording = false, browserReplayBusy = false, browserLastRecordingId = null, @@ -204,6 +211,7 @@ export default function ChatInputActionsRow({ const navigate = useNavigate(); const skillDisplayName = useSkillDisplayName(); const actionsRowRef = useRef(null); + const voiceHeldRef = useRef(false); const [isCompact, setIsCompact] = useState(false); const [skillPickerOpen, setSkillPickerOpen] = useState(false); const [expertPickerOpen, setExpertPickerOpen] = useState(false); @@ -217,6 +225,20 @@ export default function ChatInputActionsRow({ ); /** Mobile-only bottom drawer for the overflow ("more") menu. */ const [mobileOverflowOpen, setMobileOverflowOpen] = useState(false); + + // Press-and-hold dictation: start on pointerdown, finish on release or when + // the pointer slides off the button (also covers touch cancel). + const handleVoicePressStart = () => { + if (voiceHeldRef.current) return; + voiceHeldRef.current = true; + onVoiceStart?.(); + }; + + const handleVoicePressEnd = () => { + if (!voiceHeldRef.current) return; + voiceHeldRef.current = false; + onVoiceStop?.(); + }; /** Narrow-desktop overflow popover (tools / skills / …). */ const [overflowPopoverOpen, setOverflowPopoverOpen] = useState(false); /** Active sub-picker for compact layouts (drawer on mobile, panel in popover). */ @@ -1233,6 +1255,10 @@ export default function ChatInputActionsRow({ title={ !_sttAvailable ? t("voice.sttNotAvailable", "此设备不支持语音输入(需要 HTTPS)") + : voiceHoldToTalk + ? recording + ? t("voice.releaseToStop", "松开结束") + : t("voice.holdToTalk", "按住说话") : recording ? t("voice.stopRecording", "停止录音") : transcribing @@ -1246,8 +1272,29 @@ export default function ChatInputActionsRow({ recording || transcribing ? styles.secondaryBtnActive : "" }`} type="button" - disabled={disabled || isStreaming || transcribing || !_sttAvailable} - onClick={onToggleVoice} + // Dictation only fills the composer, so it stays available while a + // reply is still streaming. + disabled={disabled || transcribing || !_sttAvailable} + onClick={voiceHoldToTalk ? undefined : onToggleVoice} + onPointerDown={ + voiceHoldToTalk + ? (event) => { + event.preventDefault(); + handleVoicePressStart(); + } + : undefined + } + onPointerUp={voiceHoldToTalk ? handleVoicePressEnd : undefined} + onPointerLeave={voiceHoldToTalk ? handleVoicePressEnd : undefined} + onPointerCancel={voiceHoldToTalk ? handleVoicePressEnd : undefined} + onContextMenu={ + voiceHoldToTalk ? (event) => event.preventDefault() : undefined + } + style={ + voiceHoldToTalk + ? { touchAction: "none", userSelect: "none" } + : undefined + } > diff --git a/dashboard/src/pages/Settings/Voice/index.tsx b/dashboard/src/pages/Settings/Voice/index.tsx index 470a7842f7..354bee4e33 100644 --- a/dashboard/src/pages/Settings/Voice/index.tsx +++ b/dashboard/src/pages/Settings/Voice/index.tsx @@ -1,5 +1,14 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Button, Divider, Drawer, Form, Input, Select, Typography } from "antd"; +import { + Button, + Divider, + Drawer, + Form, + Input, + Select, + Switch, + Typography, +} from "antd"; import { message } from "@/utils/antdMessage"; import { Activity, Mic2, Check, Settings2 } from "lucide-react"; @@ -37,6 +46,8 @@ export function VoiceSettingsPanel() { const [apiKey, setApiKey] = useState(""); const [secretId, setSecretId] = useState(""); const [secretKey, setSecretKey] = useState(""); + const [realtimeStt, setRealtimeStt] = useState(false); + const [appId, setAppId] = useState(""); const [mimoEndpoint, setMimoEndpoint] = useState<"payg" | "tokenplan">( "payg", ); @@ -101,6 +112,8 @@ export function VoiceSettingsPanel() { const extra = existing?.extra ?? {}; setSecretId(String(extra.secret_id ?? "")); setSecretKey(String(extra.secret_key ?? "")); + setRealtimeStt(extra.realtime_stt === true); + setAppId(String(extra.app_id ?? "")); setMimoEndpoint(extra.endpoint_type === "tokenplan" ? "tokenplan" : "payg"); setMimoVoiceId(String(extra.voice_id ?? "冰糖")); }; @@ -115,6 +128,8 @@ export function VoiceSettingsPanel() { secret_id: secretId, secret_key: secretKey, region: "ap-guangzhou", + realtime_stt: realtimeStt, + app_id: appId.trim() || undefined, }; } else if (preset.kind === "edge") { extra = { voice_id: "zh-CN-XiaoxiaoNeural" }; @@ -152,6 +167,10 @@ export function VoiceSettingsPanel() { ? Boolean(secretId.trim() && secretKey.trim()) : Boolean(apiKey.trim()); if (!complete) message.warning(t("voice.credentialsRequired")); + if (complete && realtimeStt && !appId.trim()) { + message.warning(t("voice.realtimeAppIdRequired")); + return false; + } return complete; }; @@ -192,6 +211,9 @@ export function VoiceSettingsPanel() { } else { await voiceApi.createProvider(payload); } + // The active payload carries `stt_realtime`, so drop the cache even when + // the active provider itself does not change (e.g. the switch was toggled). + invalidateVoiceConfigCache(); if ( preset.kind !== "browser" && (preset.capability === "stt" || preset.capability === "both") && @@ -199,7 +221,6 @@ export function VoiceSettingsPanel() { ) { const next = await voiceApi.setActive({ stt: preset.id }); setActive(next); - invalidateVoiceConfigCache(); } if ( preset.kind !== "browser" && @@ -208,7 +229,6 @@ export function VoiceSettingsPanel() { ) { const next = await voiceApi.setActive({ tts: preset.id }); setActive(next); - invalidateVoiceConfigCache(); } message.success(t("voice.saved")); setConfigure(null); @@ -419,6 +439,26 @@ export function VoiceSettingsPanel() { onChange={(e) => setSecretKey(e.target.value)} /> + + + + + setAppId(e.target.value)} + /> + + {realtimeStt && ( +
+ {t("voice.realtimeSttHint")} +
+ )} )} {configure?.preset.kind === "openai" && ( diff --git a/dashboard/src/utils/pcmCapture.test.ts b/dashboard/src/utils/pcmCapture.test.ts new file mode 100644 index 0000000000..e811459e3c --- /dev/null +++ b/dashboard/src/utils/pcmCapture.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + FRAME_BYTES, + PcmFrameBuffer, + downsampleToPcm16, + floatToPcm16, +} from "./pcmCapture"; + +describe("floatToPcm16", () => { + it("clamps to the signed 16-bit range", () => { + const out = floatToPcm16(new Float32Array([0, 1, -1, 2, -2])); + expect([...out]).toEqual([0, 32767, -32768, 32767, -32768]); + }); +}); + +describe("downsampleToPcm16", () => { + it("keeps 16 kHz input as is", () => { + const out = downsampleToPcm16(new Float32Array([0, 0.5, -0.5]), 16000); + expect([...out]).toEqual([0, 16383, -16384]); + }); + + it("interpolates 48 kHz input down to a third of the samples", () => { + const input = new Float32Array(480).fill(0.5); + const out = downsampleToPcm16(input, 48000); + expect(out.length).toBe(160); + expect([...out].every((sample) => sample === 16383)).toBe(true); + }); +}); + +describe("PcmFrameBuffer", () => { + it("emits whole frames of 1280 bytes", () => { + const buffer = new PcmFrameBuffer(); + const frames = buffer.push(new Int16Array(640).fill(1)); + + expect(frames).toHaveLength(1); + expect(frames[0].byteLength).toBe(FRAME_BYTES); + expect([...frames[0].slice(0, 2)]).toEqual([1, 0]); + }); + + it("carries the remainder into the next frame", () => { + const buffer = new PcmFrameBuffer(); + + expect(buffer.push(new Int16Array(320).fill(1))).toHaveLength(0); + const frames = buffer.push(new Int16Array(320).fill(2)); + + expect(frames).toHaveLength(1); + expect([...new Int16Array(frames[0].buffer)].slice(0, 320)).toEqual( + new Array(320).fill(1), + ); + expect([...new Int16Array(frames[0].buffer)].slice(320)).toEqual( + new Array(320).fill(2), + ); + }); + + it("emits several frames for a large chunk", () => { + const buffer = new PcmFrameBuffer(); + expect(buffer.push(new Int16Array(1600))).toHaveLength(2); + }); + + it("drops the carry on reset", () => { + const buffer = new PcmFrameBuffer(); + buffer.push(new Int16Array(320).fill(1)); + buffer.reset(); + + expect(buffer.push(new Int16Array(320).fill(2))).toHaveLength(0); + const frames = buffer.push(new Int16Array(320).fill(2)); + expect([...new Int16Array(frames[0].buffer)].slice(0, 320)).toEqual( + new Array(320).fill(2), + ); + }); +}); diff --git a/dashboard/src/utils/pcmCapture.ts b/dashboard/src/utils/pcmCapture.ts new file mode 100644 index 0000000000..da42b5395c --- /dev/null +++ b/dashboard/src/utils/pcmCapture.ts @@ -0,0 +1,71 @@ +/** + * 16 kHz mono PCM helpers for realtime speech recognition. + * + * Tencent's realtime ASR expects 16-bit mono PCM at 16 kHz, sent in ~40 ms + * frames (640 samples / 1280 bytes) at roughly 1:1 realtime. + */ + +export const REALTIME_SAMPLE_RATE = 16000; +export const FRAME_SAMPLES = 640; +export const FRAME_BYTES = FRAME_SAMPLES * 2; + +function toPcm16(sample: number): number { + const clamped = Math.max(-1, Math.min(1, sample)); + return clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff; +} + +export function floatToPcm16(input: Float32Array): Int16Array { + const out = new Int16Array(input.length); + for (let i = 0; i < input.length; i += 1) { + out[i] = toPcm16(input[i]); + } + return out; +} + +/** Linearly interpolated resample to 16 kHz PCM16; adequate for speech. */ +export function downsampleToPcm16( + input: Float32Array, + inputRate: number, +): Int16Array { + if (inputRate <= REALTIME_SAMPLE_RATE) return floatToPcm16(input); + const ratio = inputRate / REALTIME_SAMPLE_RATE; + const outLength = Math.floor(input.length / ratio); + const out = new Int16Array(outLength); + for (let i = 0; i < outLength; i += 1) { + const position = i * ratio; + const left = Math.floor(position); + const right = Math.min(left + 1, input.length - 1); + const weight = position - left; + out[i] = toPcm16(input[left] * (1 - weight) + input[right] * weight); + } + return out; +} + +/** Accumulates arbitrary PCM chunks into whole {@link FRAME_BYTES} frames. */ +export class PcmFrameBuffer { + private carry = new Uint8Array(0); + + push(samples: Int16Array): Uint8Array[] { + const bytes = new Uint8Array( + samples.buffer, + samples.byteOffset, + samples.byteLength, + ); + const merged = new Uint8Array(this.carry.length + bytes.length); + merged.set(this.carry, 0); + merged.set(bytes, this.carry.length); + + const frames: Uint8Array[] = []; + let offset = 0; + while (merged.length - offset >= FRAME_BYTES) { + frames.push(merged.slice(offset, offset + FRAME_BYTES)); + offset += FRAME_BYTES; + } + this.carry = merged.slice(offset); + return frames; + } + + reset(): void { + this.carry = new Uint8Array(0); + } +} diff --git a/dashboard/src/utils/realtimeStt.test.ts b/dashboard/src/utils/realtimeStt.test.ts new file mode 100644 index 0000000000..4e00a3e083 --- /dev/null +++ b/dashboard/src/utils/realtimeStt.test.ts @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RealtimeSttClient } from "./realtimeStt"; + +class FakeSocket { + static instances: FakeSocket[] = []; + + binaryType = ""; + onopen: (() => void) | null = null; + onmessage: ((event: { data: unknown }) => void) | null = null; + onerror: (() => void) | null = null; + onclose: (() => void) | null = null; + sent: unknown[] = []; + closed = false; + readonly url: string; + + constructor(url: string) { + this.url = url; + FakeSocket.instances.push(this); + } + + send(data: unknown): void { + this.sent.push(data); + } + + close(): void { + this.closed = true; + this.onclose?.(); + } + + emitMessage(payload: unknown): void { + this.onmessage?.({ data: JSON.stringify(payload) }); + } + + emitClose(): void { + this.onclose?.(); + } + + emitError(): void { + this.onerror?.(); + } +} + +function setup() { + FakeSocket.instances = []; + vi.stubGlobal("WebSocket", FakeSocket); + const handlers = { + onInterim: vi.fn(), + onFinal: vi.fn(), + onDone: vi.fn(), + onError: vi.fn(), + }; + const client = new RealtimeSttClient( + "ws://test/api/voice/stt-stream", + handlers, + ); + return { client, handlers }; +} + +function lastSocket(): FakeSocket { + return FakeSocket.instances[FakeSocket.instances.length - 1]; +} + +describe("RealtimeSttClient", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("buffers audio until the server is ready", async () => { + const { client } = setup(); + const connected = client.connect(); + const socket = lastSocket(); + + client.send(new Uint8Array([1, 2])); + expect(socket.sent).toHaveLength(0); + + socket.emitMessage({ type: "ready" }); + await connected; + + expect(socket.sent).toEqual([new Uint8Array([1, 2])]); + }); + + it("reports the cumulative transcript", async () => { + const { client, handlers } = setup(); + const connected = client.connect(); + const socket = lastSocket(); + socket.emitMessage({ type: "ready" }); + await connected; + + socket.emitMessage({ type: "interim", text: "你好", sentence_id: 0 }); + expect(handlers.onInterim).toHaveBeenLastCalledWith("你好"); + + socket.emitMessage({ type: "final", text: "你好世界", sentence_id: 0 }); + expect(handlers.onFinal).toHaveBeenLastCalledWith("你好世界"); + + socket.emitMessage({ type: "interim", text: "再见", sentence_id: 1 }); + expect(handlers.onInterim).toHaveBeenLastCalledWith("你好世界再见"); + }); + + it("does not duplicate a sentence confirmed twice", async () => { + const { client, handlers } = setup(); + const connected = client.connect(); + const socket = lastSocket(); + socket.emitMessage({ type: "ready" }); + await connected; + + socket.emitMessage({ type: "final", text: "你好", sentence_id: 0 }); + socket.emitMessage({ type: "final", text: "你好", sentence_id: 0 }); + + expect(handlers.onFinal).toHaveBeenLastCalledWith("你好"); + }); + + it("commits a trailing interim sentence when the stream ends", async () => { + const { client, handlers } = setup(); + const connected = client.connect(); + const socket = lastSocket(); + socket.emitMessage({ type: "ready" }); + await connected; + + socket.emitMessage({ type: "interim", text: "未确认" }); + socket.emitMessage({ type: "done" }); + + expect(handlers.onFinal).toHaveBeenLastCalledWith("未确认"); + expect(handlers.onDone).toHaveBeenCalledTimes(1); + expect(handlers.onError).not.toHaveBeenCalled(); + expect(socket.closed).toBe(true); + }); + + it("sends the end frame through the socket", async () => { + const { client } = setup(); + const connected = client.connect(); + const socket = lastSocket(); + socket.emitMessage({ type: "ready" }); + await connected; + + client.end(); + + expect(JSON.parse(String(socket.sent[socket.sent.length - 1]))).toEqual({ + type: "end", + }); + }); + + it("surfaces the server error message", async () => { + const { client, handlers } = setup(); + const connected = client.connect(); + const socket = lastSocket(); + socket.emitMessage({ type: "ready" }); + await connected; + + socket.emitMessage({ type: "error", message: "资源包额度已耗尽。" }); + + expect(handlers.onError).toHaveBeenCalledWith("资源包额度已耗尽。"); + expect(handlers.onDone).not.toHaveBeenCalled(); + }); + + it("reports an unexpected close as a transport failure", async () => { + const { client, handlers } = setup(); + const connected = client.connect(); + const socket = lastSocket(); + socket.emitMessage({ type: "ready" }); + await connected; + + socket.emitClose(); + + expect(handlers.onError).toHaveBeenCalledWith(null); + }); + + it("rejects connect when the socket fails before ready", async () => { + const { client } = setup(); + const connected = client.connect(); + lastSocket().emitError(); + + await expect(connected).rejects.toThrow(); + }); + + it("stops sending once closed", async () => { + const { client } = setup(); + const connected = client.connect(); + const socket = lastSocket(); + socket.emitMessage({ type: "ready" }); + await connected; + + client.close(); + client.send(new Uint8Array([1])); + + expect(socket.sent).toHaveLength(0); + }); +}); diff --git a/dashboard/src/utils/realtimeStt.ts b/dashboard/src/utils/realtimeStt.ts new file mode 100644 index 0000000000..592eecec5b --- /dev/null +++ b/dashboard/src/utils/realtimeStt.ts @@ -0,0 +1,175 @@ +/** + * Browser client for Octop's realtime STT WebSocket (`/api/voice/stt-stream`). + * + * Tencent refines a sentence (`interim`) before confirming it (`final`), so the + * client tracks confirmed sentences by id and reports the *cumulative* + * transcript of the session. The composer can therefore replace only the tail + * of the input it owns instead of appending duplicates. + */ + +export interface RealtimeSttHandlers { + /** Cumulative transcript while the current sentence is still being refined. */ + onInterim?: (text: string) => void; + /** Cumulative transcript once a sentence is confirmed. */ + onFinal?: (text: string) => void; + /** The audio stream finished server-side. */ + onDone?: () => void; + /** ``message`` comes from the server; ``null`` means a transport failure. */ + onError?: (message: string | null) => void; +} + +interface ServerFrame { + type?: string; + text?: string; + sentence_id?: number; + message?: string; +} + +/** Sentence id used when committing a trailing interim sentence at ``done``. */ +const TRAILING_SENTENCE_ID = Number.MAX_SAFE_INTEGER; + +export class RealtimeSttClient { + private socket: WebSocket | null = null; + private readonly finals = new Map(); + private interim = ""; + private pending: Uint8Array[] = []; + private ready = false; + private finished = false; + private endRequested = false; + private closed = false; + private readonly url: string; + private readonly handlers: RealtimeSttHandlers; + + constructor(url: string, handlers: RealtimeSttHandlers = {}) { + this.url = url; + this.handlers = handlers; + } + + /** Open the socket; resolves once the server reports ``ready``. */ + connect(): Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocket(this.url); + socket.binaryType = "arraybuffer"; + this.socket = socket; + + const failConnect = (error: unknown) => reject(error); + + socket.onopen = () => { + // Nothing to send yet: PCM frames are buffered until `ready`. + }; + socket.onmessage = (event: MessageEvent) => { + const frame = parseFrame(event.data); + if (!frame) return; + if (frame.type === "ready") { + this.ready = true; + this.flushPending(); + resolve(); + return; + } + this.handleFrame(frame); + }; + socket.onerror = () => { + if (!this.ready) failConnect(new Error("realtime STT socket error")); + this.fail(null); + }; + socket.onclose = () => { + if (!this.ready) { + failConnect(new Error("realtime STT socket closed before ready")); + } + if (!this.finished && !this.closed) this.fail(null); + }; + }); + } + + /** Queue or send one PCM frame (16 kHz / 16-bit / mono). */ + send(frame: Uint8Array): void { + if (this.closed) return; + if (!this.ready) { + this.pending.push(frame); + return; + } + this.socket?.send(frame); + } + + /** Tell the server the audio stream ended and wait for its final results. */ + end(): void { + if (this.closed) return; + this.endRequested = true; + if (this.ready) this.socket?.send(JSON.stringify({ type: "end" })); + } + + /** Tear down without reporting an error (user cancelled). */ + close(): void { + this.closed = true; + this.pending = []; + this.socket?.close(); + this.socket = null; + } + + private handleFrame(frame: ServerFrame): void { + if (frame.type === "interim") { + this.interim = frame.text ?? ""; + this.handlers.onInterim?.(this.transcript()); + return; + } + if (frame.type === "final") { + this.finals.set(frame.sentence_id ?? 0, frame.text ?? ""); + this.interim = ""; + this.handlers.onFinal?.(this.transcript()); + return; + } + if (frame.type === "done") { + this.commitTrailingInterim(); + this.finished = true; + this.handlers.onDone?.(); + this.close(); + return; + } + if (frame.type === "error") { + this.fail(frame.message ?? null); + } + } + + private commitTrailingInterim(): void { + if (!this.interim) return; + this.finals.set(TRAILING_SENTENCE_ID, this.interim); + this.interim = ""; + this.handlers.onFinal?.(this.transcript()); + } + + private transcript(): string { + const confirmed = [...this.finals.entries()] + .sort(([a], [b]) => a - b) + .map(([, text]) => text) + .filter(Boolean); + if (this.interim) confirmed.push(this.interim); + return confirmed.join(""); + } + + private flushPending(): void { + if (!this.ready) return; + const queued = this.pending; + this.pending = []; + for (const frame of queued) this.socket?.send(frame); + if (this.endRequested) this.end(); + } + + private fail(message: string | null): void { + if (this.finished || this.closed) return; + this.finished = true; + this.handlers.onError?.(message); + this.close(); + } +} + +function parseFrame(data: unknown): ServerFrame | null { + if (typeof data !== "string") return null; + try { + const parsed: unknown = JSON.parse(data); + return parsed && typeof parsed === "object" + ? (parsed as ServerFrame) + : null; + } catch { + return null; + } +} diff --git a/src/octop/api/routers/voice.py b/src/octop/api/routers/voice.py index a6a811caf3..d0095914aa 100644 --- a/src/octop/api/routers/voice.py +++ b/src/octop/api/routers/voice.py @@ -2,16 +2,37 @@ from __future__ import annotations +import asyncio +import contextlib +import json from collections.abc import AsyncIterator from typing import Any, Literal -from fastapi import APIRouter, Depends, File, Form, Request, UploadFile +from fastapi import ( + APIRouter, + Depends, + File, + Form, + Query, + Request, + UploadFile, + WebSocket, + WebSocketDisconnect, +) from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field - -from octop.api.deps import current_user, get_server, require_permission +from starlette.websockets import WebSocketState + +from octop.api.deps import ( + current_user, + get_server, + require_permission, + resolve_user_from_token, +) +from octop.i18n.domains.voice import realtime_error_message from octop.infra.errors import ErrorCode, OctopError -from octop.infra.utils.locale import resolve_request_locale +from octop.infra.utils.locale import Locale, normalize_locale, resolve_request_locale +from octop.infra.voice import realtime from octop.infra.voice.manager import VoiceManager from octop.infra.voice.presets import load_voice_presets @@ -26,6 +47,17 @@ def _voice_manager(server: Any) -> VoiceManager: ) +def _active_payload(mgr: VoiceManager) -> dict[str, Any]: + """Active provider names plus whether STT can stream. + + The dashboard needs ``stt_realtime`` synchronously inside a user gesture to + decide between streaming and the one-shot upload path. + """ + active: dict[str, Any] = dict(mgr.get_active()) + active["stt_realtime"] = mgr.realtime_stt_config() is not None + return active + + def _row_to_dict(r: Any) -> dict[str, Any]: return { "id": r.id, @@ -97,8 +129,8 @@ async def list_voice_providers( async def get_active_voice( _: Any = Depends(current_user), server: Any = Depends(get_server), -) -> dict[str, str]: - return _voice_manager(server).get_active() +) -> dict[str, Any]: + return _active_payload(_voice_manager(server)) @router.put("/active") @@ -106,8 +138,10 @@ async def set_active_voice( body: ActiveVoiceBody, _: Any = Depends(require_permission("voice")), server: Any = Depends(get_server), -) -> dict[str, str]: - return _voice_manager(server).set_active(stt=body.stt, tts=body.tts) +) -> dict[str, Any]: + mgr = _voice_manager(server) + mgr.set_active(stt=body.stt, tts=body.tts) + return _active_payload(mgr) @router.post("/stt") @@ -152,6 +186,152 @@ async def _stream() -> AsyncIterator[bytes]: return StreamingResponse(_stream(), media_type=mgr.media_type(body.provider)) +async def _ws_send_json(websocket: WebSocket, payload: dict[str, Any]) -> None: + if websocket.application_state != WebSocketState.CONNECTED: + return + with contextlib.suppress(WebSocketDisconnect, RuntimeError): + await websocket.send_text(json.dumps(payload, ensure_ascii=False)) + + +async def _ws_error( + websocket: WebSocket, + code: ErrorCode, + locale: Locale, + *, + tencent_code: int | None = None, +) -> None: + message = ( + realtime_error_message(tencent_code, locale) + if tencent_code is not None + else OctopError(code, "").localized_message(locale) + ) + await _ws_send_json( + websocket, + { + "type": "error", + "code": code.value, + "message": message, + "tencent_code": tencent_code, + }, + ) + + +def _is_end_frame(raw: str) -> bool: + try: + control = json.loads(raw) + except json.JSONDecodeError: + return False + return isinstance(control, dict) and control.get("type") == "end" + + +async def _pump_events( + session: realtime.TencentRealtimeSession, websocket: WebSocket, locale: Locale +) -> None: + """Forward upstream recognition events to the browser until the stream ends.""" + async for event in session.events(): + if event.kind == "error": + await _ws_error( + websocket, + ErrorCode.VOICE_REALTIME_UNAVAILABLE, + locale, + tencent_code=event.error_code, + ) + await _ws_send_json(websocket, {"type": "done"}) + return + + if event.kind == "done": + await _ws_send_json(websocket, {"type": "done"}) + return + + await _ws_send_json( + websocket, + { + "type": event.kind, + "text": event.text, + "sentence_id": event.sentence_id, + }, + ) + + +@router.websocket("/stt-stream") +async def stream_transcription( + websocket: WebSocket, + token: str | None = Query(default=None), + locale: str | None = Query(default=None), +) -> None: + """Proxy one realtime ASR session to Tencent Cloud over WebSocket. + + The browser sends 16 kHz PCM16 mono frames as binary messages and + ``{"type": "end"}`` when it is done; the server replies with ``ready`` / + ``interim`` / ``final`` / ``done`` / ``error`` JSON messages. + """ + server = websocket.app.state.octop_server + if not token: + await websocket.close(code=4001, reason="missing token") + return + try: + resolve_user_from_token(server, token) + except OctopError as exc: + await websocket.close(code=4001, reason=f"auth failed: {exc.code.value}") + return + + await websocket.accept() + lang = normalize_locale(locale) if locale else resolve_request_locale(websocket) + + config = _voice_manager(server).realtime_stt_config() + if config is None: + await _ws_error(websocket, ErrorCode.VOICE_REALTIME_UNAVAILABLE, lang) + await websocket.close(code=4004, reason="realtime STT is not configured") + return + + session = realtime.open_session(config) + reader: asyncio.Task[None] | None = None + try: + try: + await session.connect() + except OctopError as exc: + tencent_code = exc.details.get("tencent_code") + await _ws_error( + websocket, + exc.code, + lang, + tencent_code=tencent_code if isinstance(tencent_code, int) else None, + ) + await websocket.close(code=4004, reason="upstream connect failed") + return + + await _ws_send_json(websocket, {"type": "ready"}) + reader = asyncio.create_task(_pump_events(session, websocket, lang)) + + while websocket.application_state == WebSocketState.CONNECTED: + message = await websocket.receive() + if message.get("type") == "websocket.disconnect": + break + chunk = message.get("bytes") + if chunk is not None: + await session.send_audio(chunk) + continue + raw = message.get("text") + if raw and _is_end_frame(raw): + await session.finish() + break + + if reader is not None: + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(reader, timeout=realtime.READER_TIMEOUT_SECONDS) + except WebSocketDisconnect: + pass + finally: + if reader is not None and not reader.done(): + reader.cancel() + with contextlib.suppress(asyncio.CancelledError): + await reader + await session.close() + if websocket.application_state == WebSocketState.CONNECTED: + with contextlib.suppress(RuntimeError, WebSocketDisconnect): + await websocket.close() + + @admin_router.get("") async def admin_list_voice_providers( _: Any = Depends(require_permission("voice")), diff --git a/src/octop/i18n/domains/voice.py b/src/octop/i18n/domains/voice.py index f2fc84b87c..9a7ae11761 100644 --- a/src/octop/i18n/domains/voice.py +++ b/src/octop/i18n/domains/voice.py @@ -1,4 +1,4 @@ -"""``voice.*`` — probe and Tencent provider error copy.""" +"""``voice.*`` — probe, realtime and Tencent provider error copy.""" from __future__ import annotations @@ -36,3 +36,26 @@ def format_voice_probe_error(exc: BaseException, locale: str | Locale) -> str: if sep and lookup(f"voice.tencent.{code}", locale) is not None: return tr(f"voice.tencent.{code}", locale) return raw + + +#: Realtime ASR error codes worth their own copy; anything else falls back to +#: ``voice.realtime.error_unknown``. +_ASR_ERROR_KEYS = { + 4002: "error_4002", + 4003: "error_4003", + 4004: "error_4004", + 4005: "error_4005", + 4006: "error_4006", + 4007: "error_4007", + 4008: "error_4008", + 4009: "error_4009", + 5000: "error_5000", + 5001: "error_5000", + 5002: "error_5000", +} + + +def realtime_error_message(tencent_code: int | None, locale: str | Locale) -> str: + """Localized copy for a realtime ASR failure code (``None`` = unknown).""" + suffix = _ASR_ERROR_KEYS.get(int(tencent_code or 0), "error_unknown") + return tr(f"voice.realtime.{suffix}", locale) diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index b856f3247d..bed35efda9 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -393,6 +393,7 @@ "VOICE_KIND_UNSUPPORTED": "Unsupported voice provider.", "VOICE_CAPABILITY_MISMATCH": "Voice capability does not match.", "VOICE_PROVIDER_DISABLED": "Voice provider is disabled.", + "VOICE_REALTIME_UNAVAILABLE": "Realtime speech recognition is unavailable. Check the Tencent Cloud voice settings.", "INTERNAL_ERROR": "Internal server error.", "TLS_NOT_ELIGIBLE": "TLS issuance is not available in the current server configuration.", "TLS_ISSUE_IN_PROGRESS": "A certificate issuance task is already running.", @@ -743,6 +744,18 @@ "http_status": "provider returned HTTP {status}", "network_error": "network error: {name}" }, + "realtime": { + "error_4002": "Realtime speech recognition authentication failed. Check the SecretId and SecretKey.", + "error_4003": "Realtime speech recognition is not enabled for this Tencent Cloud account.", + "error_4004": "The Tencent Cloud realtime speech recognition resource pack is exhausted.", + "error_4005": "The Tencent Cloud account is in arrears. Top up and retry.", + "error_4006": "Realtime speech recognition concurrency limit reached. Retry in a moment.", + "error_4007": "Audio decoding failed. Check the microphone input.", + "error_4008": "Audio upload timed out. Check the network connection.", + "error_4009": "The realtime speech recognition connection was closed.", + "error_5000": "The Tencent Cloud service is temporarily unavailable. Retry shortly.", + "error_unknown": "Realtime speech recognition failed. Retry in a moment." + }, "tencent": { "AuthFailure": { "SecretIdNotFound": "SecretId was not found. Check that the key is correct.", diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index bde66143aa..87c8ec6988 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -393,6 +393,7 @@ "VOICE_KIND_UNSUPPORTED": "不支持的语音提供商。", "VOICE_CAPABILITY_MISMATCH": "语音能力不匹配。", "VOICE_PROVIDER_DISABLED": "语音提供商已禁用。", + "VOICE_REALTIME_UNAVAILABLE": "实时语音识别不可用,请检查腾讯云语音配置。", "INTERNAL_ERROR": "服务器内部错误。", "TLS_NOT_ELIGIBLE": "当前服务器配置不支持 TLS 证书申请。", "TLS_ISSUE_IN_PROGRESS": "已有证书申请任务正在进行。", @@ -743,6 +744,18 @@ "http_status": "服务商返回 HTTP {status}", "network_error": "网络错误:{name}" }, + "realtime": { + "error_4002": "实时语音识别鉴权失败,请检查 SecretId / SecretKey。", + "error_4003": "当前腾讯云账号尚未开通实时语音识别服务。", + "error_4004": "腾讯云实时语音识别的资源包额度已耗尽。", + "error_4005": "腾讯云账号已欠费停服,请充值后重试。", + "error_4006": "实时语音识别并发数超限,请稍后再试。", + "error_4007": "音频解码失败,请检查麦克风输入。", + "error_4008": "音频发送超时,请保持网络稳定。", + "error_4009": "实时语音识别连接已断开。", + "error_5000": "腾讯云服务暂时不可用,请重试。", + "error_unknown": "实时语音识别失败,请稍后重试。" + }, "tencent": { "AuthFailure": { "SecretIdNotFound": "SecretId 不存在,请检查密钥是否填写正确。", diff --git a/src/octop/infra/errors.py b/src/octop/infra/errors.py index 29348b25d1..95735f3ddf 100644 --- a/src/octop/infra/errors.py +++ b/src/octop/infra/errors.py @@ -67,6 +67,7 @@ class ErrorCode(StrEnum): VOICE_KIND_UNSUPPORTED = "VOICE_KIND_UNSUPPORTED" VOICE_CAPABILITY_MISMATCH = "VOICE_CAPABILITY_MISMATCH" VOICE_PROVIDER_DISABLED = "VOICE_PROVIDER_DISABLED" + VOICE_REALTIME_UNAVAILABLE = "VOICE_REALTIME_UNAVAILABLE" INTERNAL_ERROR = "INTERNAL_ERROR" TLS_NOT_ELIGIBLE = "TLS_NOT_ELIGIBLE" TLS_ISSUE_IN_PROGRESS = "TLS_ISSUE_IN_PROGRESS" @@ -177,6 +178,7 @@ class ErrorCode(StrEnum): ErrorCode.VOICE_KIND_UNSUPPORTED: 400, ErrorCode.VOICE_CAPABILITY_MISMATCH: 400, ErrorCode.VOICE_PROVIDER_DISABLED: 400, + ErrorCode.VOICE_REALTIME_UNAVAILABLE: 400, ErrorCode.INTERNAL_ERROR: 500, ErrorCode.TLS_NOT_ELIGIBLE: 403, ErrorCode.TLS_ISSUE_IN_PROGRESS: 409, diff --git a/src/octop/infra/utils/tencent_asr_sign.py b/src/octop/infra/utils/tencent_asr_sign.py new file mode 100644 index 0000000000..5ade9bcd86 --- /dev/null +++ b/src/octop/infra/utils/tencent_asr_sign.py @@ -0,0 +1,70 @@ +"""Tencent Cloud realtime speech recognition (ASR V2) WebSocket URL signing. + +This is a *different* scheme from :mod:`octop.infra.utils.tencent_sign`, which +implements TC3-HMAC-SHA256 for the JSON APIs (``SentenceRecognition`` / +``TextToVoice``). Realtime ASR V2 authenticates through query parameters: + +1. every parameter except ``signature`` is sorted by key and joined into + ``asr.cloud.tencent.com/asr/v2/?k1=v1&k2=v2`` (no ``wss://`` prefix, + and the values are *not* percent-encoded); +2. that string is signed with ``HMAC-SHA1(secret_key)`` and base64-encoded; +3. only the resulting ``signature`` value is percent-encoded when appended to + the final URL. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import secrets +import time +from urllib.parse import quote +from uuid import uuid4 + +ASR_V2_HOST = "asr.cloud.tencent.com" +#: Large-model engine; supports Mandarin + English + Cantonese and 30 dialects. +DEFAULT_ENGINE = "16k_zh_en_2.0" +#: Tencent's ``voice_format`` token for raw 16-bit mono PCM. +PCM_VOICE_FORMAT = 1 +DEFAULT_EXPIRED_SECONDS = 3600 + + +def asr_v2_signature(origin: str, secret_key: str) -> str: + """Return the base64 HMAC-SHA1 signature for a realtime ASR ``origin``.""" + digest = hmac.new(secret_key.encode("utf-8"), origin.encode("utf-8"), hashlib.sha1).digest() + return base64.b64encode(digest).decode("ascii") + + +def build_asr_v2_url( + *, + app_id: str, + secret_id: str, + secret_key: str, + engine: str = DEFAULT_ENGINE, + voice_id: str | None = None, + timestamp: int | None = None, + nonce: int | None = None, + expired_seconds: int = DEFAULT_EXPIRED_SECONDS, +) -> str: + """Build a signed ``wss://`` URL for one realtime ASR connection. + + ``voice_id`` must be unique per connection (a new one per call by default), + and ``timestamp`` / ``nonce`` are injectable so tests can pin the signature. + """ + issued_at = int(time.time()) if timestamp is None else int(timestamp) + params: dict[str, str] = { + "secretid": secret_id, + "timestamp": str(issued_at), + "expired": str(issued_at + int(expired_seconds)), + "nonce": str(secrets.randbelow(2**31) if nonce is None else int(nonce)), + "engine_model_type": engine, + "voice_id": voice_id or uuid4().hex, + "voice_format": str(PCM_VOICE_FORMAT), + "needvad": "1", + } + ordered = sorted(params) + path = f"{ASR_V2_HOST}/asr/v2/{app_id}" + query = "&".join(f"{key}={params[key]}" for key in ordered) + signature = quote(asr_v2_signature(f"{path}?{query}", secret_key), safe="") + return f"wss://{path}?{query}&signature={signature}" diff --git a/src/octop/infra/voice/adapters.py b/src/octop/infra/voice/adapters.py index aa16a1bf77..3054b3ee33 100644 --- a/src/octop/infra/voice/adapters.py +++ b/src/octop/infra/voice/adapters.py @@ -38,7 +38,7 @@ class BrowserOnlyError(Exception): """Raised when the active provider must run in the browser.""" -def _parse_tencent_credentials(row: VoiceProviderRow) -> tuple[str, str]: +def parse_tencent_credentials(row: VoiceProviderRow) -> tuple[str, str]: extra = row.get_extra() secret_id = extra.get("secret_id") or "" secret_key = extra.get("secret_key") or "" @@ -163,7 +163,7 @@ async def transcribe_tencent( mime: str, language: str, ) -> STTResult: - secret_id, secret_key = _parse_tencent_credentials(row) + secret_id, secret_key = parse_tencent_credentials(row) extra = row.get_extra() eng = str(extra.get("eng_service_type") or "16k_zh") if language.lower().startswith("en"): @@ -209,7 +209,7 @@ async def synthesize_tencent( voice_id: str | None, speed: float, ) -> AsyncIterator[bytes]: - secret_id, secret_key = _parse_tencent_credentials(row) + secret_id, secret_key = parse_tencent_credentials(row) extra = row.get_extra() voice_type = int(voice_id or extra.get("voice_type") or 101001) payload: dict[str, Any] = { @@ -477,7 +477,7 @@ def _missing_credentials(row: VoiceProviderRow, kind: str, *, locale: str = "en" """Probe-time credential check; returns an error message when incomplete.""" if kind == "tencent": try: - _parse_tencent_credentials(row) + parse_tencent_credentials(row) except ValueError: return voice_credentials_error(kind, locale) return None diff --git a/src/octop/infra/voice/manager.py b/src/octop/infra/voice/manager.py index 6f96cae44d..455b8a604c 100644 --- a/src/octop/infra/voice/manager.py +++ b/src/octop/infra/voice/manager.py @@ -9,7 +9,7 @@ from octop.infra.db.repos.settings import SettingsRepo from octop.infra.db.repos.voice_providers import VoiceProviderRepo, VoiceProviderRow from octop.infra.errors import ErrorCode, OctopError -from octop.infra.voice import adapters +from octop.infra.voice import adapters, realtime from octop.infra.voice.presets import is_builtin_preset @@ -89,6 +89,35 @@ def media_type(self, provider_name: str | None) -> str: kind = self.resolve(name).kind return "audio/wav" if kind == "mimo" else "audio/mpeg" + def realtime_stt_config(self) -> realtime.RealtimeConfig | None: + """Credentials for streaming STT, or ``None`` when it is not available. + + Streaming only applies to Tencent Cloud, requires the ``realtime_stt`` + switch on that provider row plus an ``app_id``, and is ignored unless the + provider is the active STT service. + """ + try: + resolved = self.resolve(self.get_active()["stt"]) + except OctopError: + return None + if resolved.kind != "tencent" or resolved.row is None: + return None + extra = resolved.row.get_extra() + if not extra.get("realtime_stt"): + return None + app_id = str(extra.get("app_id") or "").strip() + if not app_id: + return None + try: + secret_id, secret_key = adapters.parse_tencent_credentials(resolved.row) + except ValueError: + return None + return realtime.RealtimeConfig( + app_id=app_id, + secret_id=secret_id, + secret_key=secret_key, + ) + async def transcribe( self, audio: bytes, diff --git a/src/octop/infra/voice/realtime.py b/src/octop/infra/voice/realtime.py new file mode 100644 index 0000000000..383c2cef93 --- /dev/null +++ b/src/octop/infra/voice/realtime.py @@ -0,0 +1,217 @@ +"""Tencent Cloud realtime speech recognition (ASR V2) upstream session. + +One :class:`TencentRealtimeSession` owns a single connection to +``wss://asr.cloud.tencent.com/asr/v2/``. It is deliberately transport +agnostic: it pushes raw PCM frames upstream and yields the parsed JSON events +back, leaving the browser-facing WebSocket, localisation and error mapping to +the HTTP layer. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from collections.abc import AsyncIterator +from dataclasses import dataclass +from typing import Any, Literal + +from octop.infra.errors import ErrorCode, OctopError +from octop.infra.utils.tencent_asr_sign import DEFAULT_ENGINE, build_asr_v2_url + +logger = logging.getLogger(__name__) + +HANDSHAKE_TIMEOUT_SECONDS = 10.0 +#: How long the router waits for ``final: 1`` after the client says "end". +READER_TIMEOUT_SECONDS = 15.0 +CLOSE_TIMEOUT_SECONDS = 5.0 +MAX_MESSAGE_BYTES = 1 << 20 + + +@dataclass(frozen=True) +class RealtimeConfig: + """Credentials needed to open one realtime ASR connection.""" + + app_id: str + secret_id: str + secret_key: str + engine: str = DEFAULT_ENGINE + + +@dataclass(frozen=True) +class RealtimeEvent: + """One normalized upstream event. + + ``interim`` text is still being refined, ``final`` text replaces that + sentence, ``done`` marks the end of the stream and ``error`` carries a + Tencent error code. + """ + + kind: Literal["interim", "final", "done", "error"] + text: str = "" + sentence_id: int = 0 + error_code: int = 0 + + +def _as_int(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 + + +def normalize_event(event: dict[str, Any]) -> RealtimeEvent | None: + """Map a raw upstream frame onto a :class:`RealtimeEvent`. + + Tencent returns two shapes for the same stream: ``16k_zh_en_2.0`` sends + ``result`` (``slice_type`` 0 = 中间结果, 1 = 稳定结果, 2 = 该句最终结果, + ``index`` = sentence id, ``voice_text_str`` = text), while the API docs + describe ``sentences``. Both are accepted. + """ + code = _as_int(event.get("code")) + if code != 0: + return RealtimeEvent(kind="error", error_code=code) + + sentences = event.get("sentences") + if isinstance(sentences, dict) and sentences.get("sentence"): + return RealtimeEvent( + kind="final" if _as_int(sentences.get("sentence_type")) == 1 else "interim", + text=str(sentences["sentence"]), + sentence_id=_as_int(sentences.get("sentence_id")), + ) + + result = event.get("result") + if isinstance(result, dict) and result.get("voice_text_str"): + return RealtimeEvent( + kind="interim" if _as_int(result.get("slice_type")) == 0 else "final", + text=str(result["voice_text_str"]), + sentence_id=_as_int(result.get("index")), + ) + + if _as_int(event.get("final")) == 1: + return RealtimeEvent(kind="done") + return None + + +def _parse_control(raw: str | bytes) -> tuple[int, str] | None: + """Return ``(code, message)`` of an upstream control frame, or ``None``.""" + try: + data = json.loads(raw if isinstance(raw, str) else raw.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + return None + if not isinstance(data, dict): + return None + try: + code = int(data.get("code") or 0) + except (TypeError, ValueError): + code = 0 + return code, str(data.get("message") or "") + + +class TencentRealtimeSession: + """A live realtime ASR stream for one browser session.""" + + def __init__(self, config: RealtimeConfig) -> None: + self._config = config + self._ws: Any = None + + @property + def connected(self) -> bool: + return self._ws is not None + + async def connect(self) -> None: + """Open the upstream connection and verify the handshake.""" + from websockets.asyncio.client import connect as ws_connect + from websockets.exceptions import WebSocketException + + url = build_asr_v2_url( + app_id=self._config.app_id, + secret_id=self._config.secret_id, + secret_key=self._config.secret_key, + engine=self._config.engine, + ) + try: + self._ws = await ws_connect( + url, + # websockets >= 14 defaults to proxy=True, which silently routes + # through HTTPS_PROXY / ALL_PROXY when the deployment sets one. + proxy=None, + # Tencent does not answer WebSocket pings; keepalive would abort. + ping_interval=None, + open_timeout=HANDSHAKE_TIMEOUT_SECONDS, + close_timeout=CLOSE_TIMEOUT_SECONDS, + max_size=MAX_MESSAGE_BYTES, + ) + raw = await asyncio.wait_for(self._ws.recv(), timeout=HANDSHAKE_TIMEOUT_SECONDS) + except (WebSocketException, OSError, TimeoutError) as exc: + await self.close() + raise OctopError( + ErrorCode.VOICE_REALTIME_UNAVAILABLE, + f"realtime ASR connect failed: {exc}", + ) from exc + + control = _parse_control(raw) + if control is None: + await self.close() + raise OctopError( + ErrorCode.VOICE_REALTIME_UNAVAILABLE, + "realtime ASR sent an unexpected handshake frame", + ) + code, message = control + if code != 0: + await self.close() + raise OctopError( + ErrorCode.VOICE_REALTIME_UNAVAILABLE, + f"realtime ASR handshake failed: {message or code}", + details={"tencent_code": code}, + ) + + async def send_audio(self, pcm: bytes) -> None: + """Forward one PCM frame (16 kHz / 16-bit / mono) upstream.""" + if self._ws is None or not pcm: + return + await self._ws.send(pcm) + + async def finish(self) -> None: + """Tell Tencent the audio stream ended so it can flush ``final``.""" + if self._ws is None: + return + await self._ws.send(json.dumps({"type": "end"})) + + async def events(self) -> AsyncIterator[RealtimeEvent]: + """Yield normalized events until the connection closes.""" + from websockets.exceptions import ConnectionClosed + + if self._ws is None: + return + while True: + try: + raw = await self._ws.recv() + except ConnectionClosed: + return + if isinstance(raw, (bytes, bytearray)): + continue + try: + frame = json.loads(raw) + except json.JSONDecodeError: + logger.debug("realtime ASR sent a non-JSON text frame") + continue + if not isinstance(frame, dict): + continue + event = normalize_event(frame) + if event is not None: + yield event + + async def close(self) -> None: + ws, self._ws = self._ws, None + if ws is None: + return + try: + await ws.close() + except Exception: # a failed close must not mask the real error + logger.debug("realtime ASR close failed", exc_info=True) + + +def open_session(config: RealtimeConfig) -> TencentRealtimeSession: + """Factory kept separate so tests can monkeypatch the upstream session.""" + return TencentRealtimeSession(config) diff --git a/tests/integration/test_voice_stt_stream_ws.py b/tests/integration/test_voice_stt_stream_ws.py new file mode 100644 index 0000000000..5936ace5c9 --- /dev/null +++ b/tests/integration/test_voice_stt_stream_ws.py @@ -0,0 +1,196 @@ +"""Realtime (streaming) Tencent STT WebSocket.""" + +from __future__ import annotations + +import json +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Any + +import pytest +from starlette.websockets import WebSocketDisconnect + +from octop.infra.errors import ErrorCode, OctopError +from octop.infra.voice import realtime +from octop.infra.voice.manager import VoiceManager +from octop.infra.voice.realtime import RealtimeEvent +from tests.support.app import octop_client +from tests.support.auth import auth_header, bootstrap_admin +from tests.support.http import ws_connect, ws_token + +TENCENT_REALTIME_EXTRA = { + "secret_id": "sid", + "secret_key": "skey", + "region": "ap-guangzhou", + "realtime_stt": True, + "app_id": "1302566622", +} + + +@pytest.fixture +async def env(tmp_octop_home: Path) -> AsyncIterator[Any]: + async with octop_client(tmp_octop_home) as (c, srv): + await bootstrap_admin(c, tmp_octop_home) + yield c, srv, await auth_header(c) + + +def _manager(srv: Any) -> VoiceManager: + return VoiceManager( + settings_repo=srv.services.settings_repo, + voice_provider_repo=srv.services.voice_provider_repo, + ) + + +def _enable_realtime(srv: Any, extra: dict[str, object] | None = None) -> None: + srv.services.voice_provider_repo.create( + name="tencent", + kind="tencent", + capability="both", + api_key="sid:skey", + extra_json=json.dumps(TENCENT_REALTIME_EXTRA if extra is None else extra), + ) + _manager(srv).set_active(stt="tencent") + + +def _stream_ws(c: Any, auth: dict[str, str] | None = None) -> Any: + query = f"?token={ws_token(auth)}&locale=zh" if auth else "?locale=zh" + return ws_connect(c._octop_app, f"/api/voice/stt-stream{query}") + + +class FakeSession: + """Stands in for the upstream Tencent connection.""" + + def __init__( + self, + frames: list[RealtimeEvent], + *, + connect_error: OctopError | None = None, + ) -> None: + self._frames = frames + self._connect_error = connect_error + self.audio: list[bytes] = [] + self.finished = False + self.closed = False + self.opened = False + + async def connect(self) -> None: + if self._connect_error is not None: + raise self._connect_error + self.opened = True + + async def send_audio(self, pcm: bytes) -> None: + self.audio.append(pcm) + + async def finish(self) -> None: + self.finished = True + + async def events(self) -> AsyncIterator[RealtimeEvent]: + for frame in self._frames: + yield frame + + async def close(self) -> None: + self.closed = True + + +def _install_fake_session( + monkeypatch: pytest.MonkeyPatch, + frames: list[RealtimeEvent], + *, + connect_error: OctopError | None = None, +) -> FakeSession: + session = FakeSession(frames, connect_error=connect_error) + monkeypatch.setattr(realtime, "open_session", lambda _config: session) + return session + + +def _sentence(text: str, *, final: bool, sentence_id: int = 0) -> RealtimeEvent: + return RealtimeEvent(kind="final" if final else "interim", text=text, sentence_id=sentence_id) + + +async def test_missing_token_is_rejected(env: Any) -> None: + c, _srv, _auth = env + with pytest.raises(WebSocketDisconnect): + async with _stream_ws(c): + pass + + +async def test_streams_audio_and_relays_results(env: Any, monkeypatch: pytest.MonkeyPatch) -> None: + c, srv, auth = env + _enable_realtime(srv) + session = _install_fake_session( + monkeypatch, + [ + _sentence("你好", final=False), + _sentence("你好世界", final=True), + RealtimeEvent(kind="done"), + ], + ) + + async with _stream_ws(c, auth) as ws: + assert await ws.receive_json() == {"type": "ready"} + await ws.send_bytes(b"\x00" * 1280) + await ws.send_bytes(b"\x01" * 1280) + await ws.send_json({"type": "end"}) + frames = await ws.drain_turn() + + assert frames == [ + {"type": "interim", "text": "你好", "sentence_id": 0}, + {"type": "final", "text": "你好世界", "sentence_id": 0}, + {"type": "done"}, + ] + assert session.audio == [b"\x00" * 1280, b"\x01" * 1280] + assert session.finished is True + assert session.closed is True + + +async def test_realtime_must_be_configured(env: Any, monkeypatch: pytest.MonkeyPatch) -> None: + c, srv, auth = env + _install_fake_session(monkeypatch, []) + + async with _stream_ws(c, auth) as ws: + frame = await ws.receive_json() + assert frame["type"] == "error" + assert frame["code"] == ErrorCode.VOICE_REALTIME_UNAVAILABLE.value + with pytest.raises(WebSocketDisconnect): + await ws.receive_json() + + +async def test_upstream_handshake_failure_is_reported( + env: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + c, srv, auth = env + _enable_realtime(srv) + _install_fake_session( + monkeypatch, + [], + connect_error=OctopError( + ErrorCode.VOICE_REALTIME_UNAVAILABLE, + "handshake failed", + details={"tencent_code": 4003}, + ), + ) + + async with _stream_ws(c, auth) as ws: + frame = await ws.receive_json() + assert frame["type"] == "error" + assert frame["tencent_code"] == 4003 + assert "实时语音识别" in frame["message"] + + +async def test_upstream_stream_error_is_forwarded( + env: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + c, srv, auth = env + _enable_realtime(srv) + _install_fake_session(monkeypatch, [RealtimeEvent(kind="error", error_code=4004)]) + + async with _stream_ws(c, auth) as ws: + assert await ws.receive_json() == {"type": "ready"} + await ws.send_bytes(b"\x00" * 1280) + + error = await ws.receive_json() + assert error["type"] == "error" + assert error["code"] == ErrorCode.VOICE_REALTIME_UNAVAILABLE.value + assert error["tencent_code"] == 4004 + assert "资源包" in error["message"] + assert await ws.receive_json() == {"type": "done"} diff --git a/tests/support/http.py b/tests/support/http.py index 1ade227e8d..b3bebdf749 100644 --- a/tests/support/http.py +++ b/tests/support/http.py @@ -95,6 +95,9 @@ async def send_json(self, payload: Any) -> None: {"type": "websocket.receive", "text": json.dumps(payload, ensure_ascii=False)}, ) + async def send_bytes(self, payload: bytes) -> None: + await self._to_app.put({"type": "websocket.receive", "bytes": payload}) + async def receive_json(self, *, timeout: float = _WS_RECEIVE_TIMEOUT_S) -> dict[str, Any]: event = await self._next_event(timeout) if event["type"] == "websocket.close": diff --git a/tests/unit/i18n/test_voice.py b/tests/unit/i18n/test_voice.py index cc045357db..a662bd8dcf 100644 --- a/tests/unit/i18n/test_voice.py +++ b/tests/unit/i18n/test_voice.py @@ -4,7 +4,11 @@ import httpx -from octop.i18n.domains.voice import format_voice_probe_error, tencent_api_language +from octop.i18n.domains.voice import ( + format_voice_probe_error, + realtime_error_message, + tencent_api_language, +) def test_tencent_secret_id_is_localized() -> None: @@ -32,3 +36,19 @@ def test_tencent_api_language_header() -> None: assert tencent_api_language("zh") == "zh-CN" assert tencent_api_language("en") == "en-US" assert tencent_api_language(None) is None + + +def test_realtime_error_codes_are_localized() -> None: + assert realtime_error_message(4003, "zh") == "当前腾讯云账号尚未开通实时语音识别服务。" + assert realtime_error_message(4004, "en") == ( + "The Tencent Cloud realtime speech recognition resource pack is exhausted." + ) + # 5001 / 5002 是同一个偶发故障文案 + assert realtime_error_message(5002, "zh") == "腾讯云服务暂时不可用,请重试。" + + +def test_unknown_realtime_code_falls_back() -> None: + assert realtime_error_message(4999, "zh") == "实时语音识别失败,请稍后重试。" + assert realtime_error_message(None, "en") == ( + "Realtime speech recognition failed. Retry in a moment." + ) diff --git a/tests/unit/test_voice_manager.py b/tests/unit/test_voice_manager.py index 66d239f64c..2b39dadc66 100644 --- a/tests/unit/test_voice_manager.py +++ b/tests/unit/test_voice_manager.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from pathlib import Path import pytest @@ -11,6 +12,7 @@ from octop.infra.db.repos.settings import SettingsRepo from octop.infra.db.repos.voice_providers import VoiceProviderRepo, VoiceProviderRow from octop.infra.errors import ErrorCode, OctopError +from octop.infra.utils.tencent_asr_sign import DEFAULT_ENGINE from octop.infra.voice import adapters from octop.infra.voice.manager import VoiceManager @@ -24,6 +26,34 @@ def voice_mgr(tmp_path: Path) -> VoiceManager: return VoiceManager(settings_repo=settings, voice_provider_repo=repo) +@pytest.fixture +def tencent_mgr(tmp_path: Path) -> tuple[VoiceManager, VoiceProviderRepo]: + db = SqlitePool(tmp_path / "octop.db") + run_migrations(db) + repo = VoiceProviderRepo(db) + manager = VoiceManager(settings_repo=SettingsRepo(db), voice_provider_repo=repo) + return manager, repo + + +def _add_tencent(repo: VoiceProviderRepo, extra: dict[str, object]) -> None: + repo.create( + name="tencent", + kind="tencent", + capability="both", + api_key="sid:skey", + extra_json=json.dumps(extra), + ) + + +TENCENT_REALTIME_EXTRA: dict[str, object] = { + "secret_id": "sid", + "secret_key": "skey", + "region": "ap-guangzhou", + "realtime_stt": True, + "app_id": "1302566622", +} + + def test_default_active_is_browser(voice_mgr: VoiceManager) -> None: assert voice_mgr.get_active() == {"stt": "browser", "tts": "browser"} @@ -88,3 +118,60 @@ async def fake_test_stt( assert captured_row.name == "draft" assert captured_row.api_key == "sk-draft" assert captured_row.base_url == "https://example.test/v1" + + +def test_realtime_config_is_none_by_default(voice_mgr: VoiceManager) -> None: + assert voice_mgr.realtime_stt_config() is None + + +def test_realtime_config_needs_tencent_as_active_stt( + tencent_mgr: tuple[VoiceManager, VoiceProviderRepo], +) -> None: + manager, repo = tencent_mgr + _add_tencent(repo, TENCENT_REALTIME_EXTRA) + + assert manager.realtime_stt_config() is None + + manager.set_active(stt="tencent") + config = manager.realtime_stt_config() + assert config is not None + assert config.app_id == "1302566622" + assert config.secret_id == "sid" + assert config.secret_key == "skey" + assert config.engine == DEFAULT_ENGINE + + +def test_realtime_config_needs_the_switch_on( + tencent_mgr: tuple[VoiceManager, VoiceProviderRepo], +) -> None: + manager, repo = tencent_mgr + _add_tencent(repo, {**TENCENT_REALTIME_EXTRA, "realtime_stt": False}) + manager.set_active(stt="tencent") + + assert manager.realtime_stt_config() is None + + +@pytest.mark.parametrize("app_id", ["", " ", None]) +def test_realtime_config_needs_an_app_id( + tencent_mgr: tuple[VoiceManager, VoiceProviderRepo], app_id: str | None +) -> None: + manager, repo = tencent_mgr + _add_tencent(repo, {**TENCENT_REALTIME_EXTRA, "app_id": app_id}) + manager.set_active(stt="tencent") + + assert manager.realtime_stt_config() is None + + +def test_realtime_config_needs_complete_credentials( + tencent_mgr: tuple[VoiceManager, VoiceProviderRepo], +) -> None: + manager, repo = tencent_mgr + repo.create( + name="tencent", + kind="tencent", + capability="both", + extra_json=json.dumps({"realtime_stt": True, "app_id": "1302566622"}), + ) + manager.set_active(stt="tencent") + + assert manager.realtime_stt_config() is None diff --git a/tests/unit/test_voice_realtime.py b/tests/unit/test_voice_realtime.py new file mode 100644 index 0000000000..5840d0dac4 --- /dev/null +++ b/tests/unit/test_voice_realtime.py @@ -0,0 +1,231 @@ +"""Unit tests for the Tencent realtime ASR upstream session.""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest +import websockets.asyncio.client as ws_client +from websockets.exceptions import ConnectionClosed + +from octop.infra.errors import ErrorCode, OctopError +from octop.infra.voice import realtime +from octop.infra.voice.realtime import RealtimeEvent + +CONFIG = realtime.RealtimeConfig(app_id="1302566622", secret_id="sid", secret_key="skey") + + +class FakeConnection: + def __init__(self, frames: list[Any]) -> None: + self._frames = list(frames) + self.sent: list[Any] = [] + self.closed = False + + async def recv(self) -> Any: + if not self._frames: + raise ConnectionClosed(None, None) + return self._frames.pop(0) + + async def send(self, payload: Any) -> None: + self.sent.append(payload) + + async def close(self) -> None: + self.closed = True + + +def _patch_connect( + monkeypatch: pytest.MonkeyPatch, + connection: FakeConnection | None, + *, + error: Exception | None = None, + capture: list[tuple[str, dict[str, Any]]] | None = None, +) -> None: + async def fake_connect(url: str, **kwargs: Any) -> FakeConnection: + if capture is not None: + capture.append((url, kwargs)) + if error is not None: + raise error + assert connection is not None + return connection + + monkeypatch.setattr(ws_client, "connect", fake_connect) + + +def _handshake(code: int = 0, message: str = "success") -> str: + return json.dumps({"code": code, "message": message, "voice_id": "v"}) + + +@pytest.mark.asyncio +async def test_connect_signs_the_url_and_accepts_the_handshake( + monkeypatch: pytest.MonkeyPatch, +) -> None: + conn = FakeConnection([_handshake()]) + capture: list[tuple[str, dict[str, Any]]] = [] + _patch_connect(monkeypatch, conn, capture=capture) + + session = realtime.open_session(CONFIG) + await session.connect() + + url, kwargs = capture[0] + assert url.startswith("wss://asr.cloud.tencent.com/asr/v2/1302566622?") + assert "engine_model_type=16k_zh_en_2.0" in url + assert "voice_format=1" in url + assert "&signature=" in url + assert kwargs["proxy"] is None + assert kwargs["ping_interval"] is None + assert session.connected is True + + +@pytest.mark.asyncio +async def test_connect_maps_a_tencent_error_code( + monkeypatch: pytest.MonkeyPatch, +) -> None: + conn = FakeConnection([_handshake(4003, "service is not open")]) + _patch_connect(monkeypatch, conn) + + session = realtime.open_session(CONFIG) + with pytest.raises(OctopError) as excinfo: + await session.connect() + + assert excinfo.value.code == ErrorCode.VOICE_REALTIME_UNAVAILABLE + assert excinfo.value.details["tencent_code"] == 4003 + assert conn.closed is True + assert session.connected is False + + +@pytest.mark.asyncio +async def test_connect_rejects_an_unexpected_handshake_frame( + monkeypatch: pytest.MonkeyPatch, +) -> None: + conn = FakeConnection(["definitely not json"]) + _patch_connect(monkeypatch, conn) + + session = realtime.open_session(CONFIG) + with pytest.raises(OctopError) as excinfo: + await session.connect() + + assert excinfo.value.code == ErrorCode.VOICE_REALTIME_UNAVAILABLE + assert "unexpected handshake" in excinfo.value.message + + +@pytest.mark.asyncio +async def test_connect_wraps_transport_failures( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_connect(monkeypatch, None, error=OSError("network down")) + + session = realtime.open_session(CONFIG) + with pytest.raises(OctopError) as excinfo: + await session.connect() + + assert excinfo.value.code == ErrorCode.VOICE_REALTIME_UNAVAILABLE + assert "network down" in excinfo.value.message + + +@pytest.mark.asyncio +async def test_audio_and_end_frames_are_forwarded( + monkeypatch: pytest.MonkeyPatch, +) -> None: + conn = FakeConnection([_handshake()]) + _patch_connect(monkeypatch, conn) + + session = realtime.open_session(CONFIG) + await session.connect() + await session.send_audio(b"\x00" * 1280) + await session.finish() + + assert conn.sent[0] == b"\x00" * 1280 + assert json.loads(conn.sent[1]) == {"type": "end"} + + +@pytest.mark.asyncio +async def test_send_before_connect_is_a_noop() -> None: + session = realtime.open_session(CONFIG) + + await session.send_audio(b"\x00" * 1280) + await session.finish() + await session.close() + + +@pytest.mark.asyncio +async def test_events_yields_only_recognized_frames( + monkeypatch: pytest.MonkeyPatch, +) -> None: + conn = FakeConnection( + [ + _handshake(), # consumed by connect() + b"\x00\x01\x02", # binary frames are not control events + "not json", + json.dumps({"code": 0, "unrelated": {}}), + json.dumps(_result_frame("你好", slice_type=0)), + json.dumps({"code": 0, "final": 1}), + ] + ) + _patch_connect(monkeypatch, conn) + + session = realtime.open_session(CONFIG) + await session.connect() + events = [event async for event in session.events()] + + assert events == [ + RealtimeEvent(kind="interim", text="你好", sentence_id=0), + RealtimeEvent(kind="done"), + ] + + +def _result_frame(text: str, *, slice_type: int, index: int = 0) -> dict[str, Any]: + return { + "code": 0, + "result": { + "slice_type": slice_type, + "index": index, + "voice_text_str": text, + "start_time": 0, + "end_time": 500, + }, + } + + +def test_normalize_result_frames() -> None: + """`16k_zh_en_2.0` answers with `result`, not the documented `sentences`.""" + interim = realtime.normalize_event(_result_frame("今天", slice_type=0)) + stable = realtime.normalize_event(_result_frame("今天天气不错", slice_type=1)) + ultimate = realtime.normalize_event(_result_frame("今天天气不错。", slice_type=2, index=1)) + + assert interim == RealtimeEvent(kind="interim", text="今天", sentence_id=0) + assert stable == RealtimeEvent(kind="final", text="今天天气不错", sentence_id=0) + assert ultimate == RealtimeEvent(kind="final", text="今天天气不错。", sentence_id=1) + + +def test_normalize_sentences_frames() -> None: + """The documented `sentences` shape is still accepted.""" + frame = { + "code": 0, + "sentences": {"sentence": "你好", "sentence_type": 1, "sentence_id": 3}, + } + assert realtime.normalize_event(frame) == RealtimeEvent( + kind="final", text="你好", sentence_id=3 + ) + + +def test_normalize_terminal_and_error_frames() -> None: + assert realtime.normalize_event({"code": 0, "final": 1}) == RealtimeEvent(kind="done") + assert realtime.normalize_event({"code": 4004, "message": "exhausted"}) == RealtimeEvent( + kind="error", error_code=4004 + ) + assert realtime.normalize_event({"code": 0, "result": {"slice_type": 1}}) is None + + +@pytest.mark.asyncio +async def test_close_is_idempotent(monkeypatch: pytest.MonkeyPatch) -> None: + conn = FakeConnection([_handshake()]) + _patch_connect(monkeypatch, conn) + + session = realtime.open_session(CONFIG) + await session.connect() + await session.close() + await session.close() + + assert conn.closed is True + assert session.connected is False diff --git a/tests/unit/utils/test_tencent_asr_sign.py b/tests/unit/utils/test_tencent_asr_sign.py new file mode 100644 index 0000000000..53b3a5ea4b --- /dev/null +++ b/tests/unit/utils/test_tencent_asr_sign.py @@ -0,0 +1,84 @@ +"""Tests for Tencent realtime ASR V2 WebSocket URL signing.""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +from urllib.parse import quote + +from octop.infra.utils.tencent_asr_sign import ( + ASR_V2_HOST, + DEFAULT_ENGINE, + DEFAULT_EXPIRED_SECONDS, + asr_v2_signature, + build_asr_v2_url, +) + +_ORIGIN = ( + f"{ASR_V2_HOST}/asr/v2/1302566622" + "?engine_model_type=16k_zh_en_2.0&expired=1700003600&needvad=1&nonce=12345" + "&secretid=AKIDSID×tamp=1700000000&voice_format=1&voice_id=vtest" +) +_SIGNATURE = "rhoZKciBwud6h4DGanqKV1YVWLw=" + + +def _fixed_url(**overrides: object) -> str: + kwargs: dict[str, object] = { + "app_id": "1302566622", + "secret_id": "AKIDSID", + "secret_key": "SKEY", + "voice_id": "vtest", + "timestamp": 1700000000, + "nonce": 12345, + } + kwargs.update(overrides) + return build_asr_v2_url(**kwargs) # type: ignore[arg-type] + + +def _query_of(url: str) -> str: + return url.split("?", 1)[1].rsplit("&signature=", 1)[0] + + +def test_signature_is_hmac_sha1_base64() -> None: + expected = base64.b64encode( + hmac.new(b"SKEY", _ORIGIN.encode("utf-8"), hashlib.sha1).digest() + ).decode("ascii") + assert expected == _SIGNATURE + assert asr_v2_signature(_ORIGIN, "SKEY") == _SIGNATURE + + +def test_build_url_matches_locked_origin_and_encoded_signature() -> None: + assert _fixed_url() == f"wss://{_ORIGIN}&signature={quote(_SIGNATURE, safe='')}" + + +def test_signature_value_is_percent_encoded() -> None: + tail = _fixed_url().split("&signature=", 1)[1] + assert tail == "rhoZKciBwud6h4DGanqKV1YVWLw%3D" + assert not any(ch in tail for ch in "=+/") + + +def test_params_are_sorted_and_pcm_is_selected() -> None: + query = _query_of(_fixed_url()) + keys = [part.split("=", 1)[0] for part in query.split("&")] + assert keys == sorted(keys) + assert "voice_format=1" in query + assert "needvad=1" in query + assert f"engine_model_type={DEFAULT_ENGINE}" in query + assert f"expired={1700000000 + DEFAULT_EXPIRED_SECONDS}" in query + + +def test_origin_values_are_not_percent_encoded() -> None: + url = _fixed_url(secret_id="sid+slash/eq=") + assert "secretid=sid+slash/eq=&" in url + + +def test_engine_is_overridable() -> None: + assert f"engine_model_type={'16k_zh'}&" in _fixed_url(engine="16k_zh") + "&" + + +def test_voice_id_is_regenerated_per_call() -> None: + first = build_asr_v2_url(app_id="a", secret_id="s", secret_key="k") + second = build_asr_v2_url(app_id="a", secret_id="s", secret_key="k") + assert first != second + assert "voice_id=" in first From dfd7fd0c9fcca513e95c87ddca1b9c7b827db5ed Mon Sep 17 00:00:00 2001 From: lihongyuan99 <64824864+lihongyuan99@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:04:12 +0800 Subject: [PATCH 002/126] fix(plugins): keep wiki_summary requests under *.wikipedia.org (#1027) wiki_summary interpolates its `lang` argument straight into the URL host (`https://{lang}.wikipedia.org/...`), and the tool schema the harness builds from the signature gives the model an unconstrained `str`. A value like "evil.com#" makes httpx resolve host=evil.com, and "localhost:8443/" reaches a loopback port -- the response's `extract` is then echoed back into the chat, so it is also a read-back channel. Validate `lang` as a bare subdomain label and return the usual error card otherwise; real codes (zh, en, zh-classical, simple, nb) are unaffected. --- CHANGELOG.md | 4 + .../plugins/bundled/wiki-summary/main.py | 11 +++ tests/unit/test_wiki_summary_plugin.py | 99 +++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 tests/unit/test_wiki_summary_plugin.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 095811131c..6a8bef8459 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ## [Unreleased] +### 修复 + +- `wiki_summary` 的 `lang` 不再被拼进请求主机名:此前传 `evil.com#` 会真的向 `https://evil.com` 发请求(`localhost:8443/` 则打本机端口),并把对方返回的摘要回显进聊天;现只接受 `zh` / `en` / `zh-classical` 这类裸子域标签,其余按「语言代码无效」返回错误卡片 + ## [1.0.2b2] - 2026-09-23 ### 新增 diff --git a/src/octop/infra/agents/plugins/bundled/wiki-summary/main.py b/src/octop/infra/agents/plugins/bundled/wiki-summary/main.py index 266edad30e..6c400de2b3 100644 --- a/src/octop/infra/agents/plugins/bundled/wiki-summary/main.py +++ b/src/octop/infra/agents/plugins/bundled/wiki-summary/main.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import re from typing import Any from urllib.parse import quote @@ -11,6 +12,11 @@ _UA = "Octop-wiki-summary/0.1.0" +# ``lang`` is interpolated into the URL host, so it must stay a bare subdomain label: +# ``evil.com#`` would otherwise send the request to evil.com, and ``localhost:8443/`` +# to the server's own loopback. +_LANG = re.compile(r"[a-z][a-z-]{1,14}") + def _payload(data: dict[str, Any], text: str) -> str: return json.dumps( @@ -59,6 +65,11 @@ async def wiki_summary(query: str, lang: str = "zh") -> str: if not q: return _payload({"error": "empty"}, "请提供词条名或关键词。") language = (lang or "zh").strip().lower() or "zh" + if not _LANG.fullmatch(language): + return _payload( + {"error": "invalid lang", "query": q, "lang": language}, + "语言代码无效:lang 只接受 zh、en、ja、zh-classical 这样的维基百科语言子域。", + ) headers = {"User-Agent": _UA} try: with httpx.Client(timeout=20.0, headers=headers, follow_redirects=True) as client: diff --git a/tests/unit/test_wiki_summary_plugin.py b/tests/unit/test_wiki_summary_plugin.py new file mode 100644 index 0000000000..572f422eff --- /dev/null +++ b/tests/unit/test_wiki_summary_plugin.py @@ -0,0 +1,99 @@ +"""Wiki-summary plugin must keep every request under ``*.wikipedia.org``.""" + +from __future__ import annotations + +import importlib.util +import json +from typing import Any + +from octop.infra.agents.plugins.bundled import default_bundled_plugins_root + + +def _load_wiki_summary() -> Any: + path = default_bundled_plugins_root() / "wiki-summary" / "main.py" + spec = importlib.util.spec_from_file_location("bundled_wiki_summary", path) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +class _FakeResp: + def __init__(self, payload: Any, status: int = 200) -> None: + self._payload = payload + self.status_code = status + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise RuntimeError(f"http {self.status_code}") + + def json(self) -> Any: + return self._payload + + +class _RecordingClient: + urls: list[str] = [] + + def __init__(self, *args: Any, **kwargs: Any) -> None: + pass + + def __enter__(self) -> _RecordingClient: + return self + + def __exit__(self, *args: Any) -> None: + return None + + def get(self, url: str, **kwargs: Any) -> _FakeResp: + type(self).urls.append(url) + if "/api/rest_v1/page/summary/" in url: + return _FakeResp({"title": "地球", "extract": "太阳系第三行星。"}) + return _FakeResp({}) + + +def _data(payload: str) -> dict[str, Any]: + return json.loads(payload)["data"] + + +async def test_plain_lang_hits_the_wikipedia_subdomain(monkeypatch: Any) -> None: + mod = _load_wiki_summary() + _RecordingClient.urls = [] + monkeypatch.setattr(mod.httpx, "Client", _RecordingClient) + out = await mod.wiki_summary("Earth", lang="en") + assert _RecordingClient.urls == ["https://en.wikipedia.org/api/rest_v1/page/summary/Earth"] + assert _data(out)["extract"] == "太阳系第三行星。" + + +async def test_hyphenated_and_simple_lang_codes_still_resolve(monkeypatch: Any) -> None: + mod = _load_wiki_summary() + _RecordingClient.urls = [] + monkeypatch.setattr(mod.httpx, "Client", _RecordingClient) + for lang in ("zh-classical", "simple", "NB"): + await mod.wiki_summary("Earth", lang=lang) + assert [u.split("/api/")[0] for u in _RecordingClient.urls] == [ + "https://zh-classical.wikipedia.org", + "https://simple.wikipedia.org", + "https://nb.wikipedia.org", + ] + + +async def test_lang_cannot_retarget_the_host(monkeypatch: Any) -> None: + """``lang`` is interpolated into the URL host, so it must not carry URL syntax. + + The tool schema gives the model a free-form ``str``, so a value like + ``evil.com#`` would otherwise send a request to an arbitrary host and echo + its JSON body back into the chat. + """ + mod = _load_wiki_summary() + monkeypatch.setattr(mod.httpx, "Client", _RecordingClient) + for raw in ( + "evil.com#", + "localhost:8443/", + "en.wikipedia.org.evil.cn", + "x/..", + "../../etc/passwd", + "zh.wikipedia.org#x", + ): + _RecordingClient.urls = [] + out = await mod.wiki_summary("Earth", lang=raw) + assert _RecordingClient.urls == [], raw + assert _data(out).get("error") == "invalid lang", raw From 5bb827f37b6c683c63b9e1f1f648173e1d4459f6 Mon Sep 17 00:00:00 2001 From: sxh Date: Thu, 24 Sep 2026 15:08:22 +0800 Subject: [PATCH 003/126] =?UTF-8?q?fix(auth):=20OCTOP=5FCAPTCHA=5FV3=5FMIN?= =?UTF-8?q?=5FSCORE=20=E5=8F=AA=E6=8E=A5=E5=8F=97=20[0,=201]=20=E5=86=85?= =?UTF-8?q?=E7=9A=84=E6=9C=89=E9=99=90=E5=80=BC=20(#1087)=20(#1088)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nan / -inf / 负数会被 float() 成功解析并原样透传给 recaptcha-v3 的判定式 `float(score) < min_score`,而该式对 NaN 恒为 False,等于静默关闭分数门槛; inf / >1 则反向锁死所有登录。现在沿用同一解析点已有的"值不可用即回落 0.5"规则,只放行 [0, 1] 内的有限值。 Co-authored-by: sxh313 <250161920+sxh313@users.noreply.github.com> Co-authored-by: jubaoliang Co-authored-by: Cursor --- CHANGELOG.md | 1 + docs/configuration.md | 2 +- src/octop/infra/auth/captcha/config.py | 4 +++ tests/integration/test_captcha_api.py | 35 +++++++++++++++++++++++++- tests/unit/auth/test_captcha_env.py | 21 ++++++++++++++++ 5 files changed, 61 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a8bef8459..6947858940 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### 修复 - `wiki_summary` 的 `lang` 不再被拼进请求主机名:此前传 `evil.com#` 会真的向 `https://evil.com` 发请求(`localhost:8443/` 则打本机端口),并把对方返回的摘要回显进聊天;现只接受 `zh` / `en` / `zh-classical` 这类裸子域标签,其余按「语言代码无效」返回错误卡片 +- 登录验证码的 `OCTOP_CAPTCHA_V3_MIN_SCORE` 只按 `float()` 解析,未校验取值:填成 `nan` 时 `score < nan` 恒为 `False`,`recaptcha-v3` 的分数门槛被静默关闭(机器分 0.0 也能登录),负数同样放行,`inf`/大于 1 则把所有登录锁死。现按该参数已有的「不可用即回落默认值」规则处理,只接受 `0`–`1` 内的有限数值 ## [1.0.2b2] - 2026-09-23 diff --git a/docs/configuration.md b/docs/configuration.md index 5e93fc6db2..92647beb60 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -147,7 +147,7 @@ Each variable, when set, takes precedence over the matching key in | `OCTOP_CAPTCHA_SECRET` | string | empty | Siteverify secret (Tencent: AppSecretKey; never logged; `GET /api/envs` redacts it) | | `OCTOP_CAPTCHA_CAM_SECRET_ID` | string | empty | Tencent only: CAM API SecretId signing `DescribeCaptchaResult`; required when the env snapshot is `tencent` | | `OCTOP_CAPTCHA_CAM_SECRET_KEY` | string | empty | Tencent only: CAM API SecretKey; never logged; `GET /api/envs` redacts it | -| `OCTOP_CAPTCHA_V3_MIN_SCORE` | float | `0.5` | Minimum `recaptcha-v3` score; admin UI is read-only | +| `OCTOP_CAPTCHA_V3_MIN_SCORE` | float | `0.5` | Minimum `recaptcha-v3` score, in the provider's `0`–`1` range; values outside it (including `nan` / `inf`) fall back to the default, same as an unparsable one; admin UI is read-only | | `OCTOP_DEFAULT_TIMEZONE` | IANA tz | `Asia/Shanghai` | Default timezone for display, scheduling, and harness (`cron_timezone` / `OCTOP_CRON_TIMEZONE` still accepted) | | `OCTOP_CORS_ORIGINS` | comma-sep list | empty | Permitted CORS origins for the dashboard / external callers | | `OCTOP_ENABLE_DASHBOARD` | bool | `true` | Serve the built React SPA at `/` | diff --git a/src/octop/infra/auth/captcha/config.py b/src/octop/infra/auth/captcha/config.py index 6ca572bc03..a46432fd41 100644 --- a/src/octop/infra/auth/captcha/config.py +++ b/src/octop/infra/auth/captcha/config.py @@ -42,6 +42,10 @@ def snapshot_env(environ: Mapping[str, str] | None = None) -> CaptchaEnv: v3_min_score = float(score_raw) if score_raw else _DEFAULT_V3_MIN_SCORE except ValueError: v3_min_score = _DEFAULT_V3_MIN_SCORE + # recaptcha-v3 scores are in [0, 1]: outside it the gate is either silently off + # (``score < nan`` is always False) or locked shut, so fall back like an unparsable value. + if not 0.0 <= v3_min_score <= 1.0: + v3_min_score = _DEFAULT_V3_MIN_SCORE return CaptchaEnv( provider=provider, site_key=(src.get("OCTOP_CAPTCHA_SITE_KEY") or "").strip(), diff --git a/tests/integration/test_captcha_api.py b/tests/integration/test_captcha_api.py index 33bc4c9383..59a442f63c 100644 --- a/tests/integration/test_captcha_api.py +++ b/tests/integration/test_captcha_api.py @@ -9,7 +9,8 @@ import pytest -from octop.infra.auth.captcha import set_test_siteverify_url +from octop.infra.auth.captcha import config as captcha_config +from octop.infra.auth.captcha import set_test_siteverify_url, snapshot_env from octop.infra.auth.captcha.store import SETTINGS_KEY from octop.infra.auth.sso.crypto import encrypt_secret from tests.support.auth import bootstrap_admin @@ -56,6 +57,7 @@ def siteverify() -> tuple[str, type[_Siteverify]]: server.server_close() set_test_siteverify_url("turnstile", None) set_test_siteverify_url("tencent", None) + set_test_siteverify_url("recaptcha-v3", None) @pytest.fixture @@ -149,6 +151,37 @@ async def test_captcha_fail_does_not_increment_lockout(client: Any, siteverify: assert int(row.login_failed_count or 0) == 0 +async def test_recaptcha_v3_gate_still_rejects_bot_score_when_env_is_unusable( + client: Any, + siteverify: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A non-numeric ``OCTOP_CAPTCHA_V3_MIN_SCORE`` must not disable the score gate.""" + url, handler = siteverify + set_test_siteverify_url("recaptcha-v3", url) + handler.payload = {"success": True, "score": 0.0, "action": "login"} + monkeypatch.setattr( + captcha_config, + "_INSTALLED", + snapshot_env( + { + "OCTOP_CAPTCHA_PROVIDER": "recaptcha-v3", + "OCTOP_CAPTCHA_SITE_KEY": "0xsite", + "OCTOP_CAPTCHA_SECRET": "test-secret", + "OCTOP_CAPTCHA_V3_MIN_SCORE": "nan", + } + ), + ) + c, _, home = client + await bootstrap_admin(c, home, username="alice", password="TestPass12") + r = await c.post( + "/api/auth/login", + json={"username": "alice", "password": "TestPass12", "captcha_token": "bot-token"}, + ) + assert r.status_code == 400 + assert r.json()["error"]["code"] == "CAPTCHA_FAILED" + + async def test_tencent_login_mocked_ok_returns_jwt(client: Any, siteverify: Any) -> None: url, handler = siteverify set_test_siteverify_url("tencent", url) diff --git a/tests/unit/auth/test_captcha_env.py b/tests/unit/auth/test_captcha_env.py index a0d11dfaad..b23c5ad50c 100644 --- a/tests/unit/auth/test_captcha_env.py +++ b/tests/unit/auth/test_captcha_env.py @@ -61,3 +61,24 @@ def test_installed_snapshot_ignores_later_os_environ_changes( monkeypatch.setenv("OCTOP_CAPTCHA_SITE_KEY", "0xsite") monkeypatch.setenv("OCTOP_CAPTCHA_SECRET", "0xsecret") assert current_env().provider == "slider" + + +@pytest.mark.parametrize( + ("raw", "expected"), + [("", 0.5), ("abc", 0.5), ("0", 0.0), ("0.9", 0.9), ("1", 1.0)], +) +def test_min_score_snapshot_keeps_in_domain_values(raw: str, expected: float) -> None: + env = snapshot_env({"OCTOP_CAPTCHA_V3_MIN_SCORE": raw}) + assert env.v3_min_score == expected + + +@pytest.mark.parametrize("raw", ["nan", "NaN", "-nan", "inf", "-inf", "1e999"]) +def test_non_finite_min_score_falls_back_to_default(raw: str) -> None: + env = snapshot_env({"OCTOP_CAPTCHA_V3_MIN_SCORE": raw}) + assert env.v3_min_score == 0.5 + + +@pytest.mark.parametrize("raw", ["-0.1", "1.5"]) +def test_out_of_score_domain_min_score_falls_back_to_default(raw: str) -> None: + env = snapshot_env({"OCTOP_CAPTCHA_V3_MIN_SCORE": raw}) + assert env.v3_min_score == 0.5 From de0f7a89a454b8ba60f04bd5f3fd86d8a82171d5 Mon Sep 17 00:00:00 2001 From: sxh Date: Thu, 24 Sep 2026 15:09:01 +0800 Subject: [PATCH 004/126] =?UTF-8?q?fix(workspace):=20PUT=20/file=20?= =?UTF-8?q?=E4=B8=8E=20POST=20/upload=20=E8=A1=A5=E4=B8=8A=20=5Fbuiltin=5F?= =?UTF-8?q?skills=20=E5=86=99=E4=BF=9D=E6=8A=A4=20(#1100)=20(#1101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同一文件里 mkdir / delete / move / PUT /doc 都用 _assert_workspace_mutable 拦下 Octop 内置技能根 _builtin_skills,只有这两个写接口漏了:owner 可以往 _builtin_skills//SKILL.md 写内容,_resolve_skill 之后会把它当作 kind="builtin" 的技能列出来,而 delete/move 与 skills 的删除接口对 builtin 一律拒绝,重启同步也只清 RETIRED_BUILTIN_SKILLS 白名单,所以写进去就删不掉。 校验放在取 workspace 之前(与其余写接口一致),upload 校验的是最终真正使用的 target,并且先于读取请求体。 Co-authored-by: sxh313 <250161920+sxh313@users.noreply.github.com> Co-authored-by: jubaoliang --- CHANGELOG.md | 1 + src/octop/api/routers/workspace.py | 4 +++- tests/integration/test_workspace_api.py | 28 +++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6947858940..9525799b9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ## [Unreleased] ### 修复 +- 工作区写入接口补上 `_builtin_skills` 保护:`PUT /workspace/file` 和 `POST /workspace/upload` 此前可以往 Octop 内置技能目录里写文件(新建目录、删除、移动、文档编辑都拒绝),写进去的文件会被当作内置技能出现在技能列表里,而删除/移动接口对该前缀一律 403,所以再也清不掉 - `wiki_summary` 的 `lang` 不再被拼进请求主机名:此前传 `evil.com#` 会真的向 `https://evil.com` 发请求(`localhost:8443/` 则打本机端口),并把对方返回的摘要回显进聊天;现只接受 `zh` / `en` / `zh-classical` 这类裸子域标签,其余按「语言代码无效」返回错误卡片 - 登录验证码的 `OCTOP_CAPTCHA_V3_MIN_SCORE` 只按 `float()` 解析,未校验取值:填成 `nan` 时 `score < nan` 恒为 `False`,`recaptcha-v3` 的分数门槛被静默关闭(机器分 0.0 也能登录),负数同样放行,`inf`/大于 1 则把所有登录锁死。现按该参数已有的「不可用即回落默认值」规则处理,只接受 `0`–`1` 内的有限数值 diff --git a/src/octop/api/routers/workspace.py b/src/octop/api/routers/workspace.py index 875b5e750a..ee2bbc778e 100644 --- a/src/octop/api/routers/workspace.py +++ b/src/octop/api/routers/workspace.py @@ -179,6 +179,7 @@ async def write_file( server: Any = Depends(get_server), ) -> dict[str, Any]: """Overwrite ``path`` with ``body.content`` (text).""" + _assert_workspace_mutable(path) ws = await require_running_workspace( agent_id, user=user, as_user=as_user, server=server, owner_only=True ) @@ -310,10 +311,11 @@ async def upload_file( server: Any = Depends(get_server), ) -> dict[str, Any]: """Upload a binary file via multipart ``file=@...``.""" + target = path or f"/{file.filename or 'upload.bin'}" + _assert_workspace_mutable(target) ws = await require_running_workspace( agent_id, user=user, as_user=as_user, server=server, owner_only=True ) - target = path or f"/{file.filename or 'upload.bin'}" data = await file.read() try: await ws.aupload_bytes( diff --git a/tests/integration/test_workspace_api.py b/tests/integration/test_workspace_api.py index 24cf69b9ef..b4f123b237 100644 --- a/tests/integration/test_workspace_api.py +++ b/tests/integration/test_workspace_api.py @@ -408,6 +408,34 @@ async def test_delete_builtin_skills_forbidden(env: Any) -> None: assert r.status_code == 403 +@pytest.mark.parametrize( + "path", + ["/_builtin_skills/foo/SKILL.md", "/.octop/_builtin_skills/foo/SKILL.md"], +) +async def test_write_builtin_skills_forbidden(env: Any, path: str) -> None: + c, _srv, auth, aid = env + r = await c.put( + f"/api/agents/{aid}/workspace/file", + params={**FROM_WORKSPACE, "path": path}, + headers=auth, + json={"content": "# injected\n"}, + ) + assert r.status_code == 403 + + +async def test_upload_builtin_skills_forbidden(env: Any) -> None: + c, srv, auth, aid = env + r = await c.post( + f"/api/agents/{aid}/workspace/upload", + params={**FROM_WORKSPACE, "path": "/_builtin_skills/foo/SKILL.md"}, + headers=auth, + files={"file": ("SKILL.md", b"# injected\n", "text/markdown")}, + ) + assert r.status_code == 403 + agent = srv.app_runtime.agent_registry.get_agent(aid) + assert await agent.workspace.aexists("_builtin_skills/foo/SKILL.md") is False + + # --- editable document (Markdown round-trip) -------------------------------- From 62218f69147ba37b73d157dc72a9d3711df53c66 Mon Sep 17 00:00:00 2001 From: sxh Date: Thu, 24 Sep 2026 15:09:19 +0800 Subject: [PATCH 005/126] =?UTF-8?q?fix(backup):=20=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=BA=20zip=20=E6=97=B6=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=20Octop=20=E8=87=AA=E6=9C=89=E7=9A=84=20=5Fbuiltin=5Fskills=20?= =?UTF-8?q?=E6=9D=A1=E7=9B=AE=20(#1104)=20(#1105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /workspace/archive 直接把归档条目写进工作区,唯一的规则 _safe_zip_name 只防目录穿越,不防保留前缀,所以一个 zip 可以在 _builtin_skills/ 和 .octop/_builtin_skills/ 下落盘。该前缀是 Octop 自有目录:delete/move 走 _assert_workspace_mutable 会拒绝它, sync_octop_builtin_skills 也只清理 RETIRED_BUILTIN_SKILLS,于是植入的 技能既按 kind="builtin" 被加载,又无法通过 API 删除。 现在在解包处过滤该前缀,并沿用已有 warnings 字段报告跳过的条数, imported 不再把保留路径算成成功导入。导出本来就会打包该前缀,但 无需在恢复时还原:每次 agent 启动都会从包内重新写入自有文件。 Co-authored-by: sxh313 <250161920+sxh313@users.noreply.github.com> Co-authored-by: jubaoliang --- CHANGELOG.md | 1 + .../infra/agents/builtin_skills/__init__.py | 19 +++++++++++- src/octop/infra/backup/workspace_archive.py | 15 +++++++++- tests/unit/backup/test_workspace_archive.py | 29 +++++++++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9525799b9b..69038479dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ## [Unreleased] ### 修复 +- 导入工作区 zip 时跳过 Octop 自有的 `_builtin_skills/` 条目:该前缀不允许删除或移动,归档里的同名文件会留下用户无法移除的技能 - 工作区写入接口补上 `_builtin_skills` 保护:`PUT /workspace/file` 和 `POST /workspace/upload` 此前可以往 Octop 内置技能目录里写文件(新建目录、删除、移动、文档编辑都拒绝),写进去的文件会被当作内置技能出现在技能列表里,而删除/移动接口对该前缀一律 403,所以再也清不掉 - `wiki_summary` 的 `lang` 不再被拼进请求主机名:此前传 `evil.com#` 会真的向 `https://evil.com` 发请求(`localhost:8443/` 则打本机端口),并把对方返回的摘要回显进聊天;现只接受 `zh` / `en` / `zh-classical` 这类裸子域标签,其余按「语言代码无效」返回错误卡片 diff --git a/src/octop/infra/agents/builtin_skills/__init__.py b/src/octop/infra/agents/builtin_skills/__init__.py index 8b73664596..d8cc26ebc6 100644 --- a/src/octop/infra/agents/builtin_skills/__init__.py +++ b/src/octop/infra/agents/builtin_skills/__init__.py @@ -14,6 +14,19 @@ _PACKAGE = "octop.infra.agents.builtin_skills" +def is_octop_builtin_skills_path(rel: str) -> bool: + """True for workspace-relative paths inside the Octop-owned built-in Skills root. + + Mirrors the two spellings ``_assert_workspace_mutable`` guards: the API name, and + the ``.octop/``-prefixed system location newer agents keep it in. + """ + posix = str(rel).replace("\\", "/").strip("/") + for prefix in (OCTOP_BUILTIN_SKILLS_ROOT, f".octop/{OCTOP_BUILTIN_SKILLS_ROOT}"): + if posix == prefix or posix.startswith(f"{prefix}/"): + return True + return False + + def _collect_files(source: Traversable, prefix: str, out: list[tuple[str, bytes]]) -> None: for entry in source.iterdir(): if entry.name.startswith((".", "__")): @@ -74,4 +87,8 @@ async def sync_octop_builtin_skills(workspace: Any) -> list[str]: return sorted(skill_names) -__all__ = ["OCTOP_BUILTIN_SKILLS_ROOT", "sync_octop_builtin_skills"] +__all__ = [ + "OCTOP_BUILTIN_SKILLS_ROOT", + "is_octop_builtin_skills_path", + "sync_octop_builtin_skills", +] diff --git a/src/octop/infra/backup/workspace_archive.py b/src/octop/infra/backup/workspace_archive.py index eb6a0167c2..427a0fbcdc 100644 --- a/src/octop/infra/backup/workspace_archive.py +++ b/src/octop/infra/backup/workspace_archive.py @@ -12,6 +12,11 @@ from harness_agent.backends.utils import materialize_storage_path +from octop.infra.agents.builtin_skills import ( + OCTOP_BUILTIN_SKILLS_ROOT, + is_octop_builtin_skills_path, +) + if TYPE_CHECKING: from harness_agent.backends.workspace import BackendWorkspace @@ -245,7 +250,15 @@ async def import_workspace_zip( "replace mode cleared only the local harness workspace; remote-only files may remain" ) - pairs = [(rel_path, blob) for rel_path, blob in entries] + # ``DELETE`` / ``move`` refuse this prefix, so writing it from an archive would + # leave entries the user cannot remove; the files Octop owns are re-seeded on + # every agent start anyway. + pairs = [(rel, blob) for rel, blob in entries if not is_octop_builtin_skills_path(rel)] + skipped = len(entries) - len(pairs) + if skipped: + warnings.append( + f"skipped {skipped} {OCTOP_BUILTIN_SKILLS_ROOT!r} entry/entries (owned by Octop)" + ) if pairs: await workspace.aupload_many(pairs) diff --git a/tests/unit/backup/test_workspace_archive.py b/tests/unit/backup/test_workspace_archive.py index b24f701704..f9aff5ec47 100644 --- a/tests/unit/backup/test_workspace_archive.py +++ b/tests/unit/backup/test_workspace_archive.py @@ -237,3 +237,32 @@ async def test_export_mountless_includes_hidden_via_aglob(tmp_path: Path) -> Non assert names == {"SOUL.md", ".octop/sessions/x.db", "outbound/a.txt"} assert zf.read(".octop/sessions/x.db") == b"db" assert zf.read("outbound/a.txt") == b"out" + + +@pytest.mark.asyncio +async def test_import_skips_octop_builtin_skills(tmp_path: Path) -> None: + """An archive must not plant entries under the Octop-owned ``_builtin_skills`` root. + + ``DELETE`` / ``move`` refuse that prefix, so a planted skill directory would be + something the user cannot remove through the API, while ``sync_octop_builtin_skills`` + only prunes the names it retired itself. + """ + ws = tmp_path / "ws" + ws.mkdir() + backend = LocalShellBackend(root_dir=str(ws), virtual_mode=False) + workspace = BackendWorkspace(backend, ws) + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("notes.md", "keep me") + zf.writestr("_builtin_skills/evil/SKILL.md", "plant") + zf.writestr(".octop/_builtin_skills/evil/SKILL.md", "plant") + data = buf.getvalue() + + result = await import_workspace_zip(workspace, data, mode="merge", local_workspace_dir=None) + + assert (ws / "notes.md").read_bytes() == b"keep me" + assert not (ws / "_builtin_skills").exists() + assert not (ws / ".octop" / "_builtin_skills").exists() + assert result["imported"] == 1 + assert any("_builtin_skills" in warning for warning in result["warnings"]) From 309db111401ffec2013417070ddd8773d5d32bd7 Mon Sep 17 00:00:00 2001 From: sxh Date: Thu, 24 Sep 2026 15:09:37 +0800 Subject: [PATCH 006/126] =?UTF-8?q?fix(users):=20=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E4=B8=8E=E7=A6=81=E7=94=A8=E7=8A=B6=E6=80=81=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E8=AF=BB=E5=BA=93=EF=BC=8C=E5=A4=9A=20worker=20=E4=B8=8B?= =?UTF-8?q?=E6=92=A4=E6=9D=83=E4=B8=8D=E5=86=8D=E6=BB=9E=E5=90=8E=20(#1102?= =?UTF-8?q?)=20(#1103)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserManager.get_by_id 与 authenticate 从进程内镜像回答,而 deps.py 的 resolve_user_from_token 每个认证请求都要经过 get_by_id。octop run --workers N,或 CLI 离线写同一 SQLite 时,另一个进程的 set_role / disable / remove 不会更新这份镜像:被降级的管理员、被禁用甚至已删除的 用户在其它 worker 上仍按旧身份通过鉴权。 现在两个读取点回到 users 行——行不存在或 disabled 时返回 None,并顺手 清掉镜像里的过期条目;authenticate 用它本来就已经取到的行构造 User。 disable / remove 一直会弹出本地镜像,所以单进程语义不变,登录响应的 role 也不再来自这份进程私有副本。 Co-authored-by: sxh313 <250161920+sxh313@users.noreply.github.com> Co-authored-by: jubaoliang --- CHANGELOG.md | 1 + src/octop/infra/users/manager.py | 59 ++++++++++++++++++------------- tests/unit/test_user_manager.py | 60 ++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69038479dc..bed4a0e265 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ## [Unreleased] ### 修复 +- 用户角色与禁用/删除状态改为按请求读库:多 worker(`octop run --workers N`)或 CLI 离线改写同一 SQLite 时,在另一个进程里被降级、禁用或删除的用户立即失效(#1102) - 导入工作区 zip 时跳过 Octop 自有的 `_builtin_skills/` 条目:该前缀不允许删除或移动,归档里的同名文件会留下用户无法移除的技能 - 工作区写入接口补上 `_builtin_skills` 保护:`PUT /workspace/file` 和 `POST /workspace/upload` 此前可以往 Octop 内置技能目录里写文件(新建目录、删除、移动、文档编辑都拒绝),写进去的文件会被当作内置技能出现在技能列表里,而删除/移动接口对该前缀一律 403,所以再也清不掉 diff --git a/src/octop/infra/users/manager.py b/src/octop/infra/users/manager.py index d0a8ec0c5d..e95a426cf0 100644 --- a/src/octop/infra/users/manager.py +++ b/src/octop/infra/users/manager.py @@ -19,7 +19,7 @@ from octop.infra.db.repos._base import UNSET from octop.infra.db.repos.audit import ACTOR_ADMIN -from octop.infra.db.repos.users import UserRepo +from octop.infra.db.repos.users import UserRepo, UserRow from octop.infra.db.services import SharedServices from octop.infra.errors import ErrorCode, OctopError from octop.infra.users.email import normalize_email, parse_optional_email @@ -79,6 +79,17 @@ def _is_unique_violation(exc: BaseException) -> bool: return pg_errors is not None and isinstance(exc, pg_errors.UniqueViolation) +def _user_from_row(row: UserRow) -> User: + return User( + id=row.id, + username=row.username, + role=Role(row.role), + display_name=row.display_name, + locale=normalize_locale(row.locale), + permissions=list(row.permissions), + ) + + class UserManager: """Owns the in-memory dict of ``User`` objects. @@ -103,15 +114,7 @@ def replace_services(self, services: SharedServices) -> None: async def boot(self) -> None: async with self._lock: for row in self._services.user_repo.list(include_disabled=False): - user = User( - id=row.id, - username=row.username, - role=Role(row.role), - display_name=row.display_name, - locale=normalize_locale(row.locale), - permissions=list(row.permissions), - ) - self._users[row.username] = user + self._users[row.username] = _user_from_row(row) async def shutdown_all(self) -> None: async with self._lock: @@ -213,10 +216,25 @@ def get(self, username: str) -> User | None: return self._users.get(username) def get_by_id(self, user_id: int) -> User | None: - for u in self._users.values(): - if u.id == user_id: - return u - return None + """Resolve from the stored row, not this process's mirror. + + Every authenticated request reads through here (``api/deps.py``), and + ``octop run --workers N`` / an offline ``octop user`` CLI write lets another + process change the row. ``disable`` and ``remove`` already clear the local + mirror, so this keeps single-worker behaviour and fixes the cross-process one. + """ + row = self._services.user_repo.get(user_id) + if row is None or row.disabled: + self._evict_cached_id(user_id) + return None + user = _user_from_row(row) + self._users[row.username] = user + return user + + def _evict_cached_id(self, user_id: int) -> None: + for username, cached in list(self._users.items()): + if cached.id == user_id: + del self._users[username] def get_row(self, user_id: int) -> Any: """Return the raw UserRow (includes disabled flag) for admin read operations.""" @@ -479,17 +497,8 @@ async def authenticate(self, username: str, password: str) -> User | None: ) return None self._services.user_repo.clear_login_lockout(row.id) - user = self._users.get(row.username) - if user is None: - user = User( - id=row.id, - username=row.username, - role=Role(row.role), - display_name=row.display_name, - locale=normalize_locale(row.locale), - permissions=list(row.permissions), - ) - self._users[row.username] = user + user = _user_from_row(row) + self._users[row.username] = user self._services.audit_repo.write(actor=row.username, action="auth.login") return user diff --git a/tests/unit/test_user_manager.py b/tests/unit/test_user_manager.py index 745129ba23..2bc0d7dbe0 100644 --- a/tests/unit/test_user_manager.py +++ b/tests/unit/test_user_manager.py @@ -470,3 +470,63 @@ async def test_boot_loads_users(tmp_path: Path): assert not hasattr(user, "agent_manager") or True # just verify no crash finally: await manager.shutdown_all() + + +async def _two_workers(tmp_path: Path) -> tuple[UserManager, UserManager]: + """Two ``UserManager``s over one DB file = ``octop run --workers 2``.""" + paths = PathLayout(tmp_path / ".octop") + paths.ensure_root() + run_migrations(SqlitePool(paths.db)) + workers = [] + for _ in range(2): + services = build_shared_services(db=SqlitePool(paths.db), paths=paths, config=OctopConfig()) + workers.append(UserManager(services)) + return workers[0], workers[1] + + +async def test_set_role_visible_to_other_worker(tmp_path: Path): + """A revoked admin must stop being admin on the worker that did not serve the call.""" + writer, other = await _two_workers(tmp_path) + created = await writer.create(username="alice", password="TestPass12", role=Role.ADMIN) + await other.boot() + assert other.get_by_id(created.id).role is Role.ADMIN + + await writer.set_role("alice", Role.USER) + assert writer.get_by_id(created.id).role is Role.USER + assert other.get_by_id(created.id).role is Role.USER + assert other.get_by_id(created.id).is_admin is False + + +async def test_disable_revokes_other_worker(tmp_path: Path): + """A disabled user must stop resolving by id on every worker, not just this one.""" + writer, other = await _two_workers(tmp_path) + created = await writer.create(username="bob", password="TestPass12", role=Role.ADMIN) + await other.boot() + assert other.get_by_id(created.id) is not None + + await writer.disable("bob") + assert writer.get_by_id(created.id) is None + assert other.get_by_id(created.id) is None + + +async def test_remove_revokes_other_worker(tmp_path: Path): + """A deleted user must stop resolving by id on every worker.""" + writer, other = await _two_workers(tmp_path) + created = await writer.create(username="carol", password="TestPass12", role=Role.ADMIN) + await other.boot() + assert other.get_by_id(created.id) is not None + + await writer.remove("carol") + assert other.get_by_id(created.id) is None + + +async def test_authenticate_returns_the_stored_role(tmp_path: Path): + """Login must hand back the row's role, not this worker's earlier copy.""" + writer, other = await _two_workers(tmp_path) + await writer.create(username="dave", password="TestPass12", role=Role.USER) + await other.boot() + await writer.set_role("dave", Role.ADMIN) + + logged_in = await other.authenticate("dave", "TestPass12") + assert logged_in is not None + assert logged_in.role is Role.ADMIN From 0559b0cf5d7526338adffced0420fba567a46a99 Mon Sep 17 00:00:00 2001 From: sxh Date: Thu, 24 Sep 2026 15:09:58 +0800 Subject: [PATCH 007/126] =?UTF-8?q?fix(agents):=20HITL=20=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E8=B7=B3=E8=BF=87=E7=AD=96=E7=95=A5=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=8C=89=E8=BF=9B=E7=A8=8B=E7=BC=93=E5=AD=98=20(#1090)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit threads.hitl_policy 是策略的唯一事实来源:每次判定都回查该行。 octop run --workers N(或 CLI 与服务并发)共用一个库时,进程内缓存会让 另一个进程撤销的跳过继续自动放行工具调用,也会让新授予的跳过不生效; 写库失败时缓存里还会留下一个从未落库的豁免策略。 Co-authored-by: sxh313 <250161920+sxh313@users.noreply.github.com> Co-authored-by: jubaoliang --- CHANGELOG.md | 1 + .../infra/agents/security/hitl_session.py | 28 +++++--- tests/unit/agents/test_hitl_session_policy.py | 71 ++++++++++++++++++- 3 files changed, 88 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bed4a0e265..335f4d8b3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ## [Unreleased] ### 修复 +- 会话级 HITL「跳过审批」不再按进程缓存:`octop run --workers N`(或 CLI 与服务并发)时,另一个进程里撤销的跳过仍会持续自动放行工具调用,新授予的也可能不生效;改为每次判定都以 `threads.hitl_policy` 为准 - 用户角色与禁用/删除状态改为按请求读库:多 worker(`octop run --workers N`)或 CLI 离线改写同一 SQLite 时,在另一个进程里被降级、禁用或删除的用户立即失效(#1102) - 导入工作区 zip 时跳过 Octop 自有的 `_builtin_skills/` 条目:该前缀不允许删除或移动,归档里的同名文件会留下用户无法移除的技能 - 工作区写入接口补上 `_builtin_skills` 保护:`PUT /workspace/file` 和 `POST /workspace/upload` 此前可以往 Octop 内置技能目录里写文件(新建目录、删除、移动、文档编辑都拒绝),写进去的文件会被当作内置技能出现在技能列表里,而删除/移动接口对该前缀一律 403,所以再也清不掉 diff --git a/src/octop/infra/agents/security/hitl_session.py b/src/octop/infra/agents/security/hitl_session.py index 10099097d1..c14d9080fb 100644 --- a/src/octop/infra/agents/security/hitl_session.py +++ b/src/octop/infra/agents/security/hitl_session.py @@ -150,35 +150,41 @@ def current_hitl_thread_id() -> str | None: class HitlSessionPolicyStore: - """In-memory cache of thread bypass policy, persisted on ``threads.hitl_policy``.""" + """Thread bypass policy, read from ``threads.hitl_policy`` on every check. + + The row is the only state a wired store keeps: ``octop run --workers N`` + (and a CLI run beside a running server) share one database, so a + process-local read-through cache would keep auto-approving tool calls after + another process revoked the bypass — and hide one another process granted. + ``_unpersisted`` is the storage for a store built without a repo (nothing + to read back from), never a cache of persisted rows. + """ def __init__(self, threads_repo: ThreadRepo | None = None) -> None: self._repo = threads_repo - self._cache: dict[str, HitlSessionPolicy] = {} + self._unpersisted: dict[str, HitlSessionPolicy] = {} def replace_repo(self, threads_repo: ThreadRepo | None) -> None: self._repo = threads_repo - self._cache.clear() + self._unpersisted.clear() def get(self, thread_id: str) -> HitlSessionPolicy: tid = (thread_id or "").strip() if not tid: return HitlSessionPolicy() - cached = self._cache.get(tid) - if cached is not None: - return cached - policy = self._load(tid) - self._cache[tid] = policy - return policy + if self._repo is None: + return self._unpersisted.get(tid) or HitlSessionPolicy() + return self._load(tid) def set(self, thread_id: str, policy: HitlSessionPolicy | object) -> HitlSessionPolicy: tid = (thread_id or "").strip() resolved = parse_hitl_session_policy(policy) if not tid: return resolved - self._cache[tid] = resolved repo = self._repo - if repo is not None: + if repo is None: + self._unpersisted[tid] = resolved + else: repo.update_composer(tid, hitl_policy=resolved.to_json()) return resolved diff --git a/tests/unit/agents/test_hitl_session_policy.py b/tests/unit/agents/test_hitl_session_policy.py index 1b24826799..8316e4b86c 100644 --- a/tests/unit/agents/test_hitl_session_policy.py +++ b/tests/unit/agents/test_hitl_session_policy.py @@ -21,6 +21,7 @@ parse_hitl_session_policy, thread_id_from_request, ) +from octop.infra.db.repos.threads import ThreadRepo @pytest.fixture @@ -100,13 +101,15 @@ def test_wrap_respects_original_when() -> None: def test_store_set_persists_once() -> None: repo = MagicMock() + repo.get.return_value = SimpleNamespace( + hitl_policy='{"mode": "allow_tools", "tools": ["bash"]}' + ) store = HitlSessionPolicyStore(repo) store.set("thr_1", {"mode": "allow_tools", "tools": ["bash"]}) repo.update_composer.assert_called_once_with( "thr_1", hitl_policy='{"mode": "allow_tools", "tools": ["bash"]}' ) assert store.get("thr_1").allows("bash") - repo.get.assert_not_called() @pytest.mark.asyncio @@ -132,6 +135,72 @@ async def fake_resume(agent_id: str, thread_id: str, decisions: list[dict[str, A assert current_hitl_thread_id() is None +@pytest.fixture +def thread_repo(tmp_path: Path) -> ThreadRepo: + from octop.infra.db.migrate import run_migrations + from octop.infra.db.pool import SqlitePool + from octop.infra.db.repos.agents import AgentRepo + from octop.infra.db.repos.users import UserRepo + + db = SqlitePool(tmp_path / "octop.db") + run_migrations(db) + UserRepo(db).create(username="u", password_hash="h", role="user") + AgentRepo(db).create(agent_id="a1", user_id=1, name="Agent 1") + repo = ThreadRepo(db) + repo.insert( + thread_id="thr_1", + agent_id="a1", + user_id=1, + channel_type="dashboard", + session_key="k", + ) + return repo + + +def test_revoked_bypass_stops_in_the_other_worker(thread_repo: ThreadRepo) -> None: + """Two ``octop run --workers`` processes share one DB: a revoke must land at once. + + A process-local cache kept auto-approving tool calls in the worker that had + read the old value, even though ``threads.hitl_policy`` was already cleared. + """ + serving_api = HitlSessionPolicyStore(thread_repo) + running_turn = HitlSessionPolicyStore(thread_repo) + + serving_api.set("thr_1", HitlSessionPolicy(mode="allow_all")) + assert running_turn.allows("thr_1", "execute") + + serving_api.set("thr_1", HitlSessionPolicy()) + assert thread_repo.get("thr_1").hitl_policy is None + assert not running_turn.allows("thr_1", "execute") + + +def test_granted_bypass_reaches_the_other_worker(thread_repo: ThreadRepo) -> None: + """The mirror case: a worker that read ``ask`` first must see a later grant.""" + serving_api = HitlSessionPolicyStore(thread_repo) + running_turn = HitlSessionPolicyStore(thread_repo) + + assert running_turn.get("thr_1").mode == "ask" + serving_api.set("thr_1", HitlSessionPolicy(mode="allow_tools", tools=["execute"])) + assert running_turn.allows("thr_1", "execute") + + +def test_bypass_is_not_active_when_persisting_failed( + thread_repo: ThreadRepo, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``set`` wrote the value in memory before the row, so a locked DB left an + unpersisted bypass active while the API still reported ``ask``.""" + + def _locked(*_args: object, **_kwargs: object) -> None: + raise RuntimeError("database is locked") + + monkeypatch.setattr(thread_repo, "update_composer", _locked) + store = HitlSessionPolicyStore(thread_repo) + with pytest.raises(RuntimeError): + store.set("thr_1", HitlSessionPolicy(mode="allow_all")) + assert not store.allows("thr_1", "execute") + + def test_build_harness_config_skips_interrupt_for_allowed_thread( manager: AgentManager, ) -> None: From aeb486c722b1f3533403efa8ea1f0f637e414ef6 Mon Sep 17 00:00:00 2001 From: lihongyuan99 <64824864+lihongyuan99@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:10:19 +0800 Subject: [PATCH 008/126] fix(security): keep IPv6 brackets and path params when rebuilding pinned URLs (#1092) Co-authored-by: jubaoliang --- CHANGELOG.md | 2 + src/octop/infra/utils/ssrf_guard.py | 7 +- .../utils/test_ssrf_guard_pinned_request.py | 76 +++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 tests/unit/utils/test_ssrf_guard_pinned_request.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 335f4d8b3f..887b41ee46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ ## [Unreleased] ### 修复 + +- 填写 IPv6 字面量地址的 MCP / OAuth 端点此前无法连通:出站请求固定 IP 时会把 URL 重建为 httpx 无法解析的形式(`InvalidURL`),同时丢弃路径参数;现按原样保留两者 - 会话级 HITL「跳过审批」不再按进程缓存:`octop run --workers N`(或 CLI 与服务并发)时,另一个进程里撤销的跳过仍会持续自动放行工具调用,新授予的也可能不生效;改为每次判定都以 `threads.hitl_policy` 为准 - 用户角色与禁用/删除状态改为按请求读库:多 worker(`octop run --workers N`)或 CLI 离线改写同一 SQLite 时,在另一个进程里被降级、禁用或删除的用户立即失效(#1102) - 导入工作区 zip 时跳过 Octop 自有的 `_builtin_skills/` 条目:该前缀不允许删除或移动,归档里的同名文件会留下用户无法移除的技能 diff --git a/src/octop/infra/utils/ssrf_guard.py b/src/octop/infra/utils/ssrf_guard.py index a1c86bedf0..cdd80dc3a7 100644 --- a/src/octop/infra/utils/ssrf_guard.py +++ b/src/octop/infra/utils/ssrf_guard.py @@ -214,8 +214,11 @@ async def safe_request( pin_ip = await _resolve_validated_ip(url) # Rebuild the request URL from validated components so the outbound call # does not reuse the original user-controlled string (CWE-918 / CodeQL). - netloc = f"{host}:{port}" if port is not None else host - request_url = urlunparse(("https", netloc, parsed.path or "/", "", parsed.query, "")) + # ``hostname`` already strips the brackets an IPv6 literal needs in a URL, + # and the path parameter lives in its own component, so both are restored. + host_part = f"[{host}]" if ":" in host else host + netloc = f"{host_part}:{port}" if port is not None else host_part + request_url = urlunparse(("https", netloc, parsed.path or "/", parsed.params, parsed.query, "")) transport = PinnedIPTransport(host, pin_ip) async with httpx.AsyncClient(transport=transport, timeout=timeout) as client: return await client.request(method, request_url, json=json, data=data, headers=headers) diff --git a/tests/unit/utils/test_ssrf_guard_pinned_request.py b/tests/unit/utils/test_ssrf_guard_pinned_request.py new file mode 100644 index 0000000000..128fae4d2a --- /dev/null +++ b/tests/unit/utils/test_ssrf_guard_pinned_request.py @@ -0,0 +1,76 @@ +"""``safe_request`` rebuilds the outbound URL; the rebuild must stay valid.""" + +from __future__ import annotations + +import asyncio +import ipaddress +from typing import Any + +import httpx + +from octop.infra.utils import ssrf_guard + +# A public anycast resolver, written the way a self-hosted IPv6 IdP would be. +_IPV6_HOST = "2606:4700:4700::1111" + + +class _Captured: + def __init__(self) -> None: + self.transport_args: list[tuple[str, str]] = [] + self.urls: list[str] = [] + + +def _install(monkeypatch: Any, captured: _Captured, pin_ip: str) -> None: + async def _fake_resolve(url: str) -> str: + return pin_ip + + class _FakeTransport(httpx.AsyncBaseTransport): + def __init__(self, target_host: str, ip: str) -> None: + captured.transport_args.append((target_host, ip)) + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + captured.urls.append(str(request.url)) + return httpx.Response(200, json={"ok": True}) + + monkeypatch.setattr(ssrf_guard, "_resolve_validated_ip", _fake_resolve) + monkeypatch.setattr(ssrf_guard, "PinnedIPTransport", _FakeTransport) + + +def test_ipv6_literal_is_a_supported_target() -> None: + """Guard the premise: validation accepts a public IPv6 literal.""" + assert not ipaddress.ip_address(_IPV6_HOST).is_private + assert ssrf_guard.validate_https_url(f"https://[{_IPV6_HOST}]/token") + + +def test_hostname_with_port_is_rebuilt_unchanged(monkeypatch: Any) -> None: + captured = _Captured() + _install(monkeypatch, captured, "203.0.113.7") + asyncio.run( + ssrf_guard.safe_request( + "POST", + "https://idp.example.com:8443/token", + data={"a": "b"}, + ), + ) + assert captured.urls == ["https://idp.example.com:8443/token"] + assert captured.transport_args == [("idp.example.com", "203.0.113.7")] + + +def test_ipv6_literal_target_keeps_its_url_brackets(monkeypatch: Any) -> None: + """``urlparse().hostname`` drops the brackets an IPv6 literal needs in a URL.""" + captured = _Captured() + _install(monkeypatch, captured, _IPV6_HOST) + asyncio.run(ssrf_guard.safe_request("POST", f"https://[{_IPV6_HOST}]/token")) + # Without the brackets httpx rejects the URL as `InvalidURL: Invalid port`. + assert captured.urls == [f"https://[{_IPV6_HOST}]/token"] + # The pin still has to match the bare address httpx hands to the backend. + assert captured.transport_args == [(_IPV6_HOST, _IPV6_HOST)] + + +def test_path_parameter_survives_the_rebuild(monkeypatch: Any) -> None: + captured = _Captured() + _install(monkeypatch, captured, "203.0.113.7") + asyncio.run( + ssrf_guard.safe_request("GET", "https://idp.example.com/mcp;session=abc?x=2"), + ) + assert captured.urls == ["https://idp.example.com/mcp;session=abc?x=2"] From af2c742801900030d4d467e141c5e581fd3afb0e Mon Sep 17 00:00:00 2001 From: sxh Date: Thu, 24 Sep 2026 15:10:42 +0800 Subject: [PATCH 009/126] fix(api): bound the thread-list limit before it reaches SQL LIMIT (#1018) GET /api/agents/{agent_id}/threads forwarded an unvalidated `limit` into `LIMIT ?`. SQLite reads a negative LIMIT as "no limit", so ?limit=-1 returned the caller's whole thread collection and ?limit=0 returned an empty page; the same statement reaches psycopg unchanged, where a negative LIMIT is an error. Bound it with Query(ge=1, le=HISTORY_MAX_LIMIT), the ceiling this module already uses for message pages. Co-authored-by: sxh Co-authored-by: sxh313 Co-authored-by: jubaoliang --- CHANGELOG.md | 1 + src/octop/api/routers/chat/history.py | 10 ++++- tests/integration/test_thread_list_api.py | 48 +++++++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 tests/integration/test_thread_list_api.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 887b41ee46..84ffdd5939 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ## [Unreleased] ### 修复 +- 会话列表接口 `GET /api/agents/{id}/threads` 的 `limit` 增加 1–200 边界(与消息分页沿用同一上限):此前负数会被 SQLite 解释成「不限制」,一次返回该用户的全部会话,`limit=0` 又返回空列表;越界请求现在统一拒绝 - 填写 IPv6 字面量地址的 MCP / OAuth 端点此前无法连通:出站请求固定 IP 时会把 URL 重建为 httpx 无法解析的形式(`InvalidURL`),同时丢弃路径参数;现按原样保留两者 - 会话级 HITL「跳过审批」不再按进程缓存:`octop run --workers N`(或 CLI 与服务并发)时,另一个进程里撤销的跳过仍会持续自动放行工具调用,新授予的也可能不生效;改为每次判定都以 `threads.hitl_policy` 为准 diff --git a/src/octop/api/routers/chat/history.py b/src/octop/api/routers/chat/history.py index 6fee8bd418..25daf5eff1 100644 --- a/src/octop/api/routers/chat/history.py +++ b/src/octop/api/routers/chat/history.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Any -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Query, Request from octop.api.common.agent import require_agent_row from octop.api.common.agent_workspace import resolve_agent_workspace_dir @@ -15,6 +15,7 @@ from octop.api.routers.chat.models import ForkThreadBody, RebindSessionBody, RenameThreadBody from octop.api.routers.chat.serialize import ( HISTORY_DEFAULT_LIMIT, + HISTORY_MAX_LIMIT, _backfill_thread_projection, _clamp_history_limit, _load_projected_thread_messages, @@ -99,7 +100,12 @@ def _require_thread( @router.get("/agents/{agent_id}/threads", summary="List threads") async def list_threads( agent_id: str, - limit: int = 50, + limit: int = Query( + default=50, + ge=1, + le=HISTORY_MAX_LIMIT, + description=f"Maximum threads to return, between 1 and {HISTORY_MAX_LIMIT}.", + ), as_user: int | None = None, user: Any = Depends(current_user), server: Any = Depends(get_server), diff --git a/tests/integration/test_thread_list_api.py b/tests/integration/test_thread_list_api.py new file mode 100644 index 0000000000..25f51e8274 --- /dev/null +++ b/tests/integration/test_thread_list_api.py @@ -0,0 +1,48 @@ +"""Page bounds for ``GET /api/agents/{agent_id}/threads``.""" + +from __future__ import annotations + +from typing import Any + +from octop.api.routers.chat.serialize import HISTORY_MAX_LIMIT + + +async def _create_threads(client: Any, auth: dict[str, str], agent_id: str, count: int) -> None: + for _ in range(count): + response = await client.post(f"/api/agents/{agent_id}/threads", headers=auth) + assert response.status_code == 201, response.text + + +async def test_thread_list_rejects_limit_below_one(env_alice_bob_agent: Any) -> None: + """A non-positive ``limit`` must not be handed to SQL ``LIMIT``. + + SQLite reads a negative LIMIT as "no limit at all" and ``LIMIT 0`` as "no rows", + so both answer a paginated request with the wrong page instead of rejecting it. + """ + client, _srv, alice, _bob, agent_id = env_alice_bob_agent + await _create_threads(client, alice, agent_id, 3) + + for bad in (0, -1): + response = await client.get(f"/api/agents/{agent_id}/threads?limit={bad}", headers=alice) + assert response.status_code == 422, ( + f"limit={bad} -> {response.status_code}: {response.text}" + ) + + +async def test_thread_list_caps_limit_like_the_message_page( + env_alice_bob_agent: Any, +) -> None: + """The thread ceiling is the one this module already uses for message pages.""" + client, _srv, alice, _bob, agent_id = env_alice_bob_agent + await _create_threads(client, alice, agent_id, 2) + + ok = await client.get( + f"/api/agents/{agent_id}/threads?limit={HISTORY_MAX_LIMIT}", headers=alice + ) + assert ok.status_code == 200, ok.text + assert len(ok.json()) == 2 + + too_big = await client.get( + f"/api/agents/{agent_id}/threads?limit={HISTORY_MAX_LIMIT + 1}", headers=alice + ) + assert too_big.status_code == 422, too_big.text From 02b807fd9e12b4e629a6eceb9bf782012b2ffd39 Mon Sep 17 00:00:00 2001 From: sxh Date: Thu, 24 Sep 2026 15:11:04 +0800 Subject: [PATCH 010/126] fix(knowledge): keep the suffix that keys a renamed document (#1108) files.document_path() keys the stored bytes on {doc_id}{suffix}, so a rename that drops or swaps the extension moves the row off its own file: the original-file route 404s, delete_document() unlinks the wrong key with missing_ok=True and leaks the bytes, and re-saving an md/txt document writes a second file. rename_document() now restores the document suffix the way create_text_document() already normalises it; folders and names that already carry the right extension are untouched. Closes #1107 Co-authored-by: sxh313 <250161920+sxh313@users.noreply.github.com> Co-authored-by: jubaoliang --- CHANGELOG.md | 1 + src/octop/infra/knowledge/service.py | 7 +++++++ tests/unit/knowledge/test_service_acl.py | 26 ++++++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84ffdd5939..352fa54771 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ## [Unreleased] ### 修复 +- 知识库文档重命名保留存储键的文件后缀:改名去掉 `.pdf` 等后缀后,原文下载/预览会 404、删除也会留下孤儿文件;现按 `create_text_document` 的既有做法补回原后缀,目录改名不受影响 (#1107) - 会话列表接口 `GET /api/agents/{id}/threads` 的 `limit` 增加 1–200 边界(与消息分页沿用同一上限):此前负数会被 SQLite 解释成「不限制」,一次返回该用户的全部会话,`limit=0` 又返回空列表;越界请求现在统一拒绝 - 填写 IPv6 字面量地址的 MCP / OAuth 端点此前无法连通:出站请求固定 IP 时会把 URL 重建为 httpx 无法解析的形式(`InvalidURL`),同时丢弃路径参数;现按原样保留两者 diff --git a/src/octop/infra/knowledge/service.py b/src/octop/infra/knowledge/service.py index 1c153e1f24..945b7acbdc 100644 --- a/src/octop/infra/knowledge/service.py +++ b/src/octop/infra/knowledge/service.py @@ -439,6 +439,13 @@ def rename_document( cleaned = (new_name or "").strip() if not cleaned or "/" in cleaned or "\\" in cleaned: raise ValueError("invalid knowledge document name") + if not document.is_dir: + # ``files.document_path`` keys the stored bytes on ``{doc_id}{suffix}``, so a + # rename that drops or swaps the extension moves the row off its own file: + # download and preview 404 and delete can no longer reclaim the bytes. + suffix = Path(document.filename).suffix.lower() + if suffix and Path(cleaned).suffix.lower() != suffix: + cleaned = f"{Path(cleaned).stem}{suffix}" new_path = normalize_kb_path(f"{path_parent(document.path)}/{cleaned}") if new_path == document.path: return cast(KnowledgeDocumentRow, document) diff --git a/tests/unit/knowledge/test_service_acl.py b/tests/unit/knowledge/test_service_acl.py index 7c6bdcc05e..a680ca396c 100644 --- a/tests/unit/knowledge/test_service_acl.py +++ b/tests/unit/knowledge/test_service_acl.py @@ -277,3 +277,29 @@ def test_update_base_validates_max_documents_range(service: KnowledgeService) -> # Omit keeps value service.update_base(kb.id, actor_user_id=owner, description="keep") assert service.get_readable_base(kb.id, actor_user_id=owner).max_documents == 10_000 + + +def test_rename_keeps_the_suffix_that_keys_the_stored_bytes( + service: KnowledgeService, +) -> None: + from octop.infra.knowledge.files import document_path + + owner = service._services.user_repo.create(username="owner", password_hash="h", role="user") + kb = service.create_base(owner_user_id=owner, name="Docs") + doc = service.upload_document( + kb.id, + actor_user_id=owner, + filename="report.pdf", + content_type="application/pdf", + content=b"%PDF-1.4", + ) + stored = document_path(kb.id, doc.id, doc.filename) + assert stored.is_file() + + renamed = service.rename_document( + kb.id, doc.id, new_name="quarterly report", actor_user_id=owner + ) + + assert renamed.filename.lower().endswith(".pdf") + assert service.document_has_original(renamed) is True + assert stored.is_file() From 4e09aeb95600fc408dba30ee2f5e5ecccfa049d6 Mon Sep 17 00:00:00 2001 From: sxh Date: Thu, 24 Sep 2026 15:11:19 +0800 Subject: [PATCH 011/126] fix(knowledge): map per-base document cap to KNOWLEDGE_DOC_LIMIT (#1013) `_map_knowledge_error` matched the literal "at most 100" to classify the document-cap error, but `KnowledgeRepo.create_document` interpolates the per-base `max_documents`, which has been user-configurable since 90b8ca25. Any cap other than the default 100 therefore fell through to the base-cap branch (the repo message also contains "knowledge bases") and the user was told they had created too many knowledge bases. Classify both caps by the stable wording of their own messages instead of a magic number, and drive the mapping from the real repo in a regression test so the wording coupling cannot silently rot again. Co-authored-by: sxh Co-authored-by: sxh313 Co-authored-by: jubaoliang --- CHANGELOG.md | 1 + src/octop/api/routers/knowledge_bases.py | 8 +++- tests/unit/api/test_knowledge_bases.py | 58 ++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 352fa54771..366a508bc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ## [Unreleased] ### 修复 +- 知识库文档数达到上限时不再答非所问:单库 `max_documents` 可配置之后,上传超限文档仍按字面量「at most 100」匹配错误文案,只有上限恰好是默认值 100 的库才报对,其余(如 2、500)会返回 409 `KNOWLEDGE_BASE_LIMIT`「每个用户最多可创建 20 个知识库」;现按两条报错各自的稳定措辞区分,超限一律正确返回 `KNOWLEDGE_DOC_LIMIT` - 知识库文档重命名保留存储键的文件后缀:改名去掉 `.pdf` 等后缀后,原文下载/预览会 404、删除也会留下孤儿文件;现按 `create_text_document` 的既有做法补回原后缀,目录改名不受影响 (#1107) - 会话列表接口 `GET /api/agents/{id}/threads` 的 `limit` 增加 1–200 边界(与消息分页沿用同一上限):此前负数会被 SQLite 解释成「不限制」,一次返回该用户的全部会话,`limit=0` 又返回空列表;越界请求现在统一拒绝 diff --git a/src/octop/api/routers/knowledge_bases.py b/src/octop/api/routers/knowledge_bases.py index 231dfdf82e..a2bee5dd3a 100644 --- a/src/octop/api/routers/knowledge_bases.py +++ b/src/octop/api/routers/knowledge_bases.py @@ -223,7 +223,9 @@ def _map_knowledge_error( ) ): return OctopError.localized(ErrorCode.KNOWLEDGE_PREREQUISITES_FAILED, locale) - if "at most 100" in text: + if "support at most" in text: + # ``KnowledgeRepo.create_document`` embeds the per-base ``max_documents``, + # which is user-configurable — never match on a literal limit here. return OctopError.localized(ErrorCode.KNOWLEDGE_DOC_LIMIT, locale) if "document size exceeds" in text: max_mb = _max_upload_mb(server) if server is not None else DEFAULT_MAX_UPLOAD_MB @@ -233,7 +235,9 @@ def _map_knowledge_error( details={"max_mb": max_mb}, max_mb=max_mb, ) - if "at most" in text and "knowledge bases" in text: + if "can own at most" in text: + # Distinct from the document-limit wording above, which also mentions + # "knowledge bases" ("knowledge bases support at most N documents"). return OctopError.localized(ErrorCode.KNOWLEDGE_BASE_LIMIT, locale) if "unsupported knowledge document content type" in text: return OctopError.localized(ErrorCode.KNOWLEDGE_UNSUPPORTED_TYPE, locale) diff --git a/tests/unit/api/test_knowledge_bases.py b/tests/unit/api/test_knowledge_bases.py index d5069bfe8c..5e0e48d5a6 100644 --- a/tests/unit/api/test_knowledge_bases.py +++ b/tests/unit/api/test_knowledge_bases.py @@ -11,6 +11,7 @@ from starlette.requests import Request from octop.infra.errors import ErrorCode, OctopError +from octop.infra.knowledge.service import MAX_BASES_PER_OWNER, MAX_DOCS_PER_KB def _request() -> Request: @@ -732,3 +733,60 @@ def test_map_knowledge_error_prerequisites_distinguished() -> None: ) assert err_model.code == ErrorCode.KNOWLEDGE_PREREQUISITES_FAILED assert err_model.status == 409 + + +def test_custom_document_cap_is_not_reported_as_base_cap( + tmp_path: Path, +) -> None: + """Hitting a per-base ``max_documents`` must not come back as ``KNOWLEDGE_BASE_LIMIT``. + + ``KnowledgeRepo.create_document`` interpolates the configurable cap into its + message, so the router cannot key off a literal limit. + """ + from octop.api.routers.knowledge_bases import _map_knowledge_error + from octop.infra.db.migrate import run_migrations + from octop.infra.db.pool import SqlitePool + from octop.infra.db.repos.knowledge import KnowledgeRepo + from octop.infra.db.repos.users import UserRepo + + pool = SqlitePool(tmp_path / "octop.db") + run_migrations(pool) + owner_id = UserRepo(pool).create(username="owner", password_hash="h", role="user") + repo = KnowledgeRepo(pool) + cap = 2 + assert cap != MAX_DOCS_PER_KB + kb = repo.create_base(owner_user_id=owner_id, name="Capped", max_documents=cap) + + overflow: ValueError | None = None + for index in range(cap + 1): + try: + repo.create_document( + kb_id=kb.id, + filename=f"doc-{index}.md", + content_type="text/markdown", + byte_size=2, + max_documents=cap, + ) + except ValueError as exc: + overflow = exc + break + + assert overflow is not None, f"cap of {cap} was not enforced: {overflow}" + assert str(cap) in str(overflow) + + err = _map_knowledge_error(overflow, locale="en") + assert err.code == ErrorCode.KNOWLEDGE_DOC_LIMIT + assert err.status == 409 + + +def test_owner_base_cap_still_maps_to_base_limit() -> None: + from octop.api.routers.knowledge_bases import _map_knowledge_error + + message = f"a user can own at most {MAX_BASES_PER_OWNER} knowledge bases" + err = _map_knowledge_error(ValueError(message), locale="en") + assert err.code == ErrorCode.KNOWLEDGE_BASE_LIMIT + assert err.status == 409 + + err_zh = _map_knowledge_error(ValueError(message), locale="zh") + assert err_zh.code == ErrorCode.KNOWLEDGE_BASE_LIMIT + assert "知识库" in err_zh.message From 689216e4b6d02438407ccdbadc3baf7eb51a2a05 Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Thu, 24 Sep 2026 17:22:49 +0800 Subject: [PATCH 012/126] feat: admin user batch policies, token UX, default FS root, and media push hardening (#1114) * feat: admin user batch policies, token UX, default FS root, and media push hardening Add admin user batch enable/disable/delete and resource policies (token quota, max experts), improve token inputs with K/M presets, default unrestricted local backends to host filesystem root, and tighten gateway tool-media push with clearer path-outside-root stream errors. Co-authored-by: Cursor * fix(workspace): treat Windows drive roots as host-root sentinels Default FS-root backends use C:/ on Windows; treating that as a scoped jail put workspaces under the drive root and broke bootstrap/invite tests. Co-authored-by: Cursor --------- Co-authored-by: jubaoliang Co-authored-by: Cursor --- .../components/TokenCountInput.module.less | 56 ++ dashboard/src/components/TokenCountInput.tsx | 106 +++ dashboard/src/locales/en.json | 57 +- dashboard/src/locales/zh.json | 57 +- .../src/pages/Admin/Users/UsersListPanel.tsx | 603 +++++++++++++++++- .../src/pages/Admin/Users/index.module.less | 101 +++ .../Experts/components/AgentBackendFields.tsx | 6 +- .../agentBackendForm.skillPackages.test.ts | 4 + .../Experts/components/agentBackendForm.ts | 11 +- .../components/modals/ModelListEditor.tsx | 32 +- src/octop/api/routers/filesystem.py | 22 +- src/octop/api/routers/setup.py | 2 + src/octop/api/routers/users.py | 148 ++++- src/octop/i18n/domains/stream.py | 6 + src/octop/i18n/en.json | 4 +- src/octop/i18n/zh.json | 4 +- src/octop/infra/agents/default_agent.py | 28 +- src/octop/infra/agents/manager.py | 12 +- .../agents/middleware/thread_artifacts.py | 8 +- src/octop/infra/agents/workspace_dir.py | 14 +- src/octop/infra/db/repos/agents.py | 18 + src/octop/infra/errors.py | 2 + src/octop/infra/gateway/media/tool_media.py | 157 ++++- src/octop/infra/gateway/process/processor.py | 14 +- .../infra/gateway/process/stream_project.py | 43 +- src/octop/infra/users/manager.py | 17 + src/octop/infra/users/resource_policy.py | 42 +- src/octop/infra/utils/host_dirs.py | 35 +- tests/integration/test_filesystem_api.py | 26 +- tests/integration/test_invites_api.py | 8 +- tests/integration/test_setup_bootstrap.py | 12 +- tests/integration/test_users_api.py | 160 +++++ tests/unit/agents/test_default_agent.py | 35 +- tests/unit/agents/test_thread_artifacts.py | 1 + tests/unit/agents/test_workspace_dir.py | 19 + tests/unit/gateway/test_tool_media.py | 227 +++++++ tests/unit/i18n/test_stream.py | 22 + tests/unit/infra/utils/test_host_dirs.py | 23 +- tests/unit/users/test_resource_policy.py | 27 + 39 files changed, 1967 insertions(+), 202 deletions(-) create mode 100644 dashboard/src/components/TokenCountInput.module.less create mode 100644 dashboard/src/components/TokenCountInput.tsx diff --git a/dashboard/src/components/TokenCountInput.module.less b/dashboard/src/components/TokenCountInput.module.less new file mode 100644 index 0000000000..4e7fc40b65 --- /dev/null +++ b/dashboard/src/components/TokenCountInput.module.less @@ -0,0 +1,56 @@ +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.presets { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; +} + +.presetsLabel { + font-size: 12px; + color: var(--fn-text-tertiary); + margin-right: 2px; +} + +.preset { + appearance: none; + border: 1px solid var(--fn-border-primary); + background: var(--fn-bg-primary); + color: var(--fn-text-secondary); + border-radius: 6px; + padding: 1px 6px; + font-size: 12px; + line-height: 18px; + cursor: pointer; + transition: + border-color 0.15s ease, + color 0.15s ease, + background 0.15s ease; + + &:hover:not(:disabled) { + border-color: var(--fn-primary, #1677ff); + color: var(--fn-primary, #1677ff); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } +} + +.presetActive { + border-color: var(--fn-primary, #1677ff); + color: var(--fn-primary, #1677ff); + background: color-mix(in srgb, var(--fn-primary, #1677ff) 10%, transparent); +} + +.preview { + font-size: 12px; + line-height: 1.4; + color: var(--fn-text-tertiary); +} diff --git a/dashboard/src/components/TokenCountInput.tsx b/dashboard/src/components/TokenCountInput.tsx new file mode 100644 index 0000000000..a238ca3e05 --- /dev/null +++ b/dashboard/src/components/TokenCountInput.tsx @@ -0,0 +1,106 @@ +/** + * Numeric token input with quick presets and a human-readable K/M preview. + * Stores absolute token counts; presets/preview prefer K (or M when ≥1M). + */ +import { InputNumber } from "antd"; +import { useTranslation } from "react-i18next"; +import styles from "./TokenCountInput.module.less"; + +export function formatTokenScaleLabel(tokens: number): string { + if (tokens >= 1_000_000) { + const millions = tokens / 1_000_000; + return `${trimScale(millions)}M`; + } + if (tokens >= 1_000) { + const kilos = tokens / 1_000; + return `${trimScale(kilos)}K`; + } + return String(tokens); +} + +function trimScale(n: number): string { + if (Number.isInteger(n)) return String(n); + return n.toFixed(2).replace(/\.?0+$/, ""); +} + +function formatExact(tokens: number): string { + return tokens.toLocaleString("en-US"); +} + +export interface TokenCountInputProps { + value?: number | null; + onChange?: (value: number | null) => void; + placeholder?: string; + /** Absolute token counts shown as quick-fill chips. */ + presets?: readonly number[]; + min?: number; + step?: number; + disabled?: boolean; +} + +export function TokenCountInput({ + value, + onChange, + placeholder, + presets = [], + min = 0, + step = 1_000, + disabled, +}: TokenCountInputProps) { + const { t } = useTranslation(); + const numeric = + typeof value === "number" && Number.isFinite(value) ? value : null; + + return ( +
+ onChange?.(typeof next === "number" ? next : null)} + min={min} + step={step} + disabled={disabled} + style={{ width: "100%" }} + placeholder={placeholder} + formatter={(raw) => + `${raw ?? ""}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",") + } + parser={(raw) => { + const cleaned = (raw ?? "").replace(/,/g, ""); + if (!cleaned) return undefined as unknown as number; + return Number(cleaned); + }} + /> + {presets.length > 0 ? ( +
+ + {t("common.tokenCountPresets")} + + {presets.map((preset) => { + const selected = numeric === preset; + return ( + + ); + })} +
+ ) : null} + {numeric != null && numeric > 0 ? ( +
+ {t("common.tokenCountPreview", { + label: formatTokenScaleLabel(numeric), + exact: formatExact(numeric), + })} +
+ ) : null} +
+ ); +} diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index b476993cd6..0968f1788b 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -73,7 +73,9 @@ "unknown": "Unknown" }, "copyErrorForOctop": "Copy error details", - "installLogEmpty": "(No install log available)" + "installLogEmpty": "(No install log available)", + "tokenCountPresets": "Quick fill", + "tokenCountPreview": "About {{label}} tokens ({{exact}})" }, "apiErrors": { "AUTH_FAILED": "Authentication failed.", @@ -176,6 +178,7 @@ "WORKSPACE_ROOT_RESTRICTED": "The selected storage root is outside the allowed directory for this account.", "WORKSPACE_ROOT_CONTAINER_UNSUPPORTED": "Allowed storage root cannot be set while Octop is running in a container.", "TOKEN_QUOTA_EXCEEDED": "This account has reached its token quota. Please contact an administrator to raise the limit ({{used}}/{{quota}}).", + "AGENT_QUOTA_EXCEEDED": "This account has reached its expert limit. Please contact an administrator to raise the limit ({{used}}/{{quota}}).", "CAPTCHA_REQUIRED": "This server requires a captcha token to sign in.", "CAPTCHA_FAILED": "Captcha verification failed. Try again.", "CONFIG_FILE_CORRUPT": "Cannot update {{path}}: the file is not valid JSON ({{detail}}). Fix the syntax and retry — no settings were changed.", @@ -959,12 +962,10 @@ "backendLabel": "Storage Backend", "backendRootDir": "Storage Root Dir", "backendRootDirPlaceholder": "Browse and select a directory", - "backendRootDirDesc": "Defaults to the server user's home directory ({{home}}). You may also browse from filesystem root `/`.", + "backendRootDirDesc": "Defaults to filesystem root `/`. You may also browse and pick another directory.", "backendRootDirDescContainer": "Running in a container: defaults to filesystem root `/` (the container is already the isolation boundary). You may still browse and pick another directory.", - "backendRootDirDescAdmin": "Defaults to the server user's home directory ({{home}}). You may also browse from filesystem root `/`.", "backendRootDirJailHint": "Saving to a non-root directory enables a sandbox and confines the AI filesystem to that folder. External ACP runners can’t be used with this setup; connecting via IDE / octop acp is unaffected.", "backendRootDirImmutableHint": "Cannot be changed after creation.", - "rootDirOutsideHomeWarning": "This path is outside your home directory ({{home}}).", "rootDirListFailed": "Could not list subdirectories. Check permissions or choose another path.", "rootDirMkdir": "New folder", "rootDirRename": "Rename folder", @@ -1149,7 +1150,8 @@ "recursion_limit": "The agent hit its max iteration / recursion limit before finishing. Open Configuration and raise Max Iterations, then try again.", "timeout_network": "Connecting to the model timed out or the network failed. Check your network and retry. If you use a proxy or self-hosted endpoint, confirm it is reachable.", "provider_unavailable": "The model provider is temporarily unavailable. Retry later, or switch to another model.", - "model_call_failed": "The model call failed after several retries. Wait a moment and retry. If it keeps failing, check the model settings or switch models." + "model_call_failed": "The model call failed after several retries. Wait a moment and retry. If it keeps failing, check the model settings or switch models.", + "path_outside_root": "The file path is outside this expert's storage root, so it cannot be read or written. Use a path under the storage root, or widen the storage root in expert settings (on Windows, set an explicit drive path such as D:/octop-data)." }, "chat": { "sharedExpert": { @@ -3043,9 +3045,9 @@ "showAdvanced": "Advanced Options", "hideAdvanced": "Hide Advanced", "contextWindow": "Context Window (tokens)", - "contextWindowPlaceholder": "e.g. 128000", + "contextWindowPlaceholder": "e.g. 128000 (128K)", "maxTokens": "Max Output Tokens", - "maxTokensPlaceholder": "e.g. 16000", + "maxTokensPlaceholder": "e.g. 16000 (16K)", "reasoning": "Reasoning Support", "reasoningToggle": "Allow toggle", "reasoningDefault": "Default thinking", @@ -4932,9 +4934,46 @@ "policyWorkspaceRootRequired": "Choose an allowed storage root", "policyWorkspaceRootContainerHint": "Not available in container deployments — the container filesystem is already the isolation boundary, and new experts default to `/`.", "policyTokenQuota": "Token usage limit", - "policyTokenQuotaHint": "Lifetime input + output tokens across this user's experts. Off means no limit.", + "policyTokenQuotaHint": "Lifetime input + output tokens across this user's experts. Off means no limit. Typical values are in millions (1M = 1,000,000 tokens).", "policyTokenQuotaRequired": "Enter a token quota", - "modalEditTitle": "Edit user {{username}}" + "policyTokenQuotaPlaceholder": "e.g. 10000000 (about 10 million)", + "policyTokenQuotaPresets": "Quick fill", + "policyTokenQuotaPreset": "{{millions}}M", + "policyTokenQuotaPreview": "About {{millions}} million tokens ({{exact}})", + "policyMaxAgents": "Expert limit", + "policyMaxAgentsHint": "How many experts this user may create (teams excluded). Off means no limit.", + "policyMaxAgentsRequired": "Enter an expert limit", + "policyMaxAgentsPlaceholder": "e.g. 5", + "modalEditTitle": "Edit user {{username}}", + "batchSelected": "{{count}} selected", + "batchSelectAll": "Select all", + "batchSelectUser": "Select user {{username}}", + "batchClear": "Clear selection", + "batchEnable": "Enable", + "batchDisable": "Disable", + "batchSetToken": "Set token limit", + "batchSetTokenTitle": "Set token limit for {{count}} users", + "batchSetTokenHint": "Applies the same token quota to all selected users. Turn the switch off for unlimited.", + "batchSetMaxAgents": "Set expert limit", + "batchSetMaxAgentsTitle": "Set expert limit for {{count}} users", + "batchSetMaxAgentsHint": "Applies the same expert creation limit to all selected users. Turn the switch off for unlimited.", + "batchEnableConfirm": "Enable {{count}} selected users?", + "batchEnableHint": "Enabled users can sign in and use the system again.", + "batchDisableConfirm": "Disable {{count}} selected users?", + "batchDisableHint": "Disabled users cannot sign in. Your own account is skipped automatically.", + "batchDeleteConfirm": "Delete {{count}} selected users?", + "batchDeleteHint": "Your own account is skipped automatically. This cannot be undone.", + "batchSuccess": "{{action}} completed for {{count}} users", + "batchPartial": "{{action}} finished: {{succeeded}} succeeded, {{failed}} failed", + "batchAllFailed": "{{action}} failed for all {{failed}} users", + "batchFailed": "Batch operation failed", + "batchAction": { + "enable": "Enable", + "disable": "Disable", + "delete": "Delete", + "set_token_quota": "Set token limit", + "set_max_agents": "Set expert limit" + } }, "adminSso": { "panelTitle": "Single sign-on", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 318d8ca0bc..a8d507fa59 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -73,7 +73,9 @@ "unknown": "未知" }, "copyErrorForOctop": "复制错误信息", - "installLogEmpty": "(暂无安装日志)" + "installLogEmpty": "(暂无安装日志)", + "tokenCountPresets": "快速填写", + "tokenCountPreview": "约 {{label}} tokens({{exact}})" }, "apiErrors": { "AUTH_FAILED": "认证失败。", @@ -176,6 +178,7 @@ "WORKSPACE_ROOT_RESTRICTED": "所选存储根目录超出了该账号允许的范围。", "WORKSPACE_ROOT_CONTAINER_UNSUPPORTED": "容器环境下不能设置「允许的存储根目录」。", "TOKEN_QUOTA_EXCEEDED": "该账号已达到 token 配额上限,请联系管理员提升额度({{used}}/{{quota}})。", + "AGENT_QUOTA_EXCEEDED": "该账号已达到专家数量上限,请联系管理员提升额度({{used}}/{{quota}})。", "CAPTCHA_REQUIRED": "此服务器登录需要验证码令牌。", "CAPTCHA_FAILED": "验证码校验失败,请重试。", "CONFIG_FILE_CORRUPT": "无法更新 {{path}}:该文件不是合法 JSON({{detail}})。请修正语法后重试;现有配置未被改动。", @@ -960,12 +963,10 @@ "backendLabel": "存储后端", "backendRootDir": "存储根目录", "backendRootDirPlaceholder": "浏览并选择目录", - "backendRootDirDesc": "默认使用当前系统用户的 home 目录({{home}})。也可从文件系统根目录 `/` 起浏览并选择。", + "backendRootDirDesc": "默认使用文件系统根目录 `/`。也可浏览并选择其他目录。", "backendRootDirDescContainer": "当前运行在容器环境中,默认使用文件系统根目录 `/`(容器本身已是隔离边界)。也可浏览并选择其他目录。", - "backendRootDirDescAdmin": "默认使用当前系统用户的 home 目录({{home}})。也可从文件系统根目录 `/` 起浏览并选择。", "backendRootDirJailHint": "保存至非根目录时,会启用沙箱环境并将 AI 文件系统限制在该目录内。此配置下无法调用外部 ACP Runner;通过 IDE / octop acp 接入不受影响。", "backendRootDirImmutableHint": "创建后不可更改。", - "rootDirOutsideHomeWarning": "当前路径不在你的 home 目录({{home}})内。", "rootDirListFailed": "无法列出子目录,请检查权限或选择其他路径。", "rootDirMkdir": "新建文件夹", "rootDirRename": "重命名文件夹", @@ -1149,7 +1150,8 @@ "recursion_limit": "智能体已达到最大迭代次数(递归上限),任务尚未完成。请前往「运行配置」调高「最大迭代次数」后重试。", "timeout_network": "连接模型服务超时或网络异常。请检查网络后重试;若使用代理或自建服务,请确认其可达。", "provider_unavailable": "模型服务暂时不可用。请稍后重试,或切换其他模型。", - "model_call_failed": "模型调用多次重试后仍失败。请稍后重试;若持续失败,请检查模型配置或更换模型。" + "model_call_failed": "模型调用多次重试后仍失败。请稍后重试;若持续失败,请检查模型配置或更换模型。", + "path_outside_root": "文件路径超出当前专家的存储根目录,无法读写。请改用存储根目录内的路径,或在专家设置中扩大存储根目录(Windows 上请显式指定盘符路径,例如 D:/octop-data)。" }, "chat": { "sharedExpert": { @@ -3041,9 +3043,9 @@ "showAdvanced": "高级选项", "hideAdvanced": "收起高级", "contextWindow": "上下文窗口(Token 数)", - "contextWindowPlaceholder": "如 128000", + "contextWindowPlaceholder": "如 128000(128K)", "maxTokens": "最大输出 Token", - "maxTokensPlaceholder": "如 16000", + "maxTokensPlaceholder": "如 16000(16K)", "reasoning": "支持推理", "reasoningToggle": "允许开关", "reasoningDefault": "默认思考", @@ -5071,9 +5073,46 @@ "policyWorkspaceRootRequired": "请选择允许的存储根目录", "policyWorkspaceRootContainerHint": "容器环境下不可设置——容器文件系统本身已是隔离边界,创建专家时默认使用 `/`。", "policyTokenQuota": "Token 使用上限", - "policyTokenQuotaHint": "该用户所有专家累计的输入+输出 token。关闭表示不限制。", + "policyTokenQuotaHint": "该用户所有专家累计的输入+输出 token。关闭表示不限制。常用单位为百万(1M = 100 万 tokens)。", "policyTokenQuotaRequired": "请输入 token 上限", - "modalEditTitle": "编辑用户 {{username}}" + "policyTokenQuotaPlaceholder": "例如 10000000(约 10 百万)", + "policyTokenQuotaPresets": "快速填写", + "policyTokenQuotaPreset": "{{millions}} 百万", + "policyTokenQuotaPreview": "约 {{millions}} 百万 tokens({{exact}})", + "policyMaxAgents": "专家数量上限", + "policyMaxAgentsHint": "该用户可创建的专家数量(不含团队)。关闭表示不限制。", + "policyMaxAgentsRequired": "请输入专家数量上限", + "policyMaxAgentsPlaceholder": "例如 5", + "modalEditTitle": "编辑用户 {{username}}", + "batchSelected": "已选 {{count}} 人", + "batchSelectAll": "全选", + "batchSelectUser": "选择用户 {{username}}", + "batchClear": "取消选择", + "batchEnable": "启用", + "batchDisable": "禁用", + "batchSetToken": "设置 Token 上限", + "batchSetTokenTitle": "为 {{count}} 个用户设置 Token 上限", + "batchSetTokenHint": "将对所选用户统一设置 Token 使用上限;关闭开关表示不限制。", + "batchSetMaxAgents": "设置专家上限", + "batchSetMaxAgentsTitle": "为 {{count}} 个用户设置专家数量上限", + "batchSetMaxAgentsHint": "将对所选用户统一设置可创建的专家数量;关闭开关表示不限制。", + "batchEnableConfirm": "启用选中的 {{count}} 个用户?", + "batchEnableHint": "启用后这些用户可以再次登录并使用系统。", + "batchDisableConfirm": "禁用选中的 {{count}} 个用户?", + "batchDisableHint": "禁用后这些用户将无法登录。当前登录用户会被自动跳过。", + "batchDeleteConfirm": "删除选中的 {{count}} 个用户?", + "batchDeleteHint": "当前登录用户会被自动跳过。此操作不可撤销。", + "batchSuccess": "已对 {{count}} 个用户完成「{{action}}」", + "batchPartial": "「{{action}}」完成:成功 {{succeeded}},失败 {{failed}}", + "batchAllFailed": "「{{action}}」全部失败({{failed}})", + "batchFailed": "批量操作失败", + "batchAction": { + "enable": "启用", + "disable": "禁用", + "delete": "删除", + "set_token_quota": "设置 Token 上限", + "set_max_agents": "设置专家上限" + } }, "adminSso": { "panelTitle": "单点登录", diff --git a/dashboard/src/pages/Admin/Users/UsersListPanel.tsx b/dashboard/src/pages/Admin/Users/UsersListPanel.tsx index 4c5645ea4f..5de5e1a787 100644 --- a/dashboard/src/pages/Admin/Users/UsersListPanel.tsx +++ b/dashboard/src/pages/Admin/Users/UsersListPanel.tsx @@ -10,6 +10,7 @@ * Endpoints (all require admin role; backend returns 403 otherwise): * GET /api/users * POST /api/users + * POST /api/users/batch * PATCH /api/users/{id} * POST /api/users/{id}/reset-password * DELETE /api/users/{id} @@ -43,6 +44,7 @@ import { ChevronRight, CircleHelp, Clock, + Coins, IdCard, KeyRound, LayoutGrid, @@ -52,6 +54,8 @@ import { LockOpen, Pencil, Plus, + Power, + PowerOff, RefreshCw, Search, ShieldCheck, @@ -59,6 +63,7 @@ import { User, UserRound, Mail, + X, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { request } from "../../../api/request"; @@ -96,6 +101,7 @@ interface UserRow { permissions?: string[]; workspace_root_dir?: string | null; token_quota?: number | null; + max_agents?: number | null; } interface PermissionCatalogItem { @@ -116,6 +122,8 @@ interface PolicyFormValues { workspace_root_dir?: string; limit_token_quota?: boolean; token_quota?: number | null; + limit_max_agents?: boolean; + max_agents?: number | null; } interface CreateValues extends PolicyFormValues { @@ -174,6 +182,8 @@ interface UserCardGridProps { agentsLoading: boolean; currentUserId: number | null; permLabelByKey: Map; + selectedIds: number[]; + onToggleSelect: (id: number, checked: boolean) => void; onTogglePatch: ( row: UserRow, patch: Partial>, @@ -186,6 +196,35 @@ interface UserCardGridProps { nowSec: number; } +type BatchAction = + | "enable" + | "disable" + | "delete" + | "set_token_quota" + | "set_max_agents"; + +interface BatchResponse { + action: BatchAction; + results: { + user_id: number; + ok: boolean; + error?: string | null; + code?: string | null; + }[]; + succeeded: number; + failed: number; +} + +interface BatchTokenFormValues { + limit_token_quota?: boolean; + token_quota?: number | null; +} + +interface BatchMaxAgentsFormValues { + limit_max_agents?: boolean; + max_agents?: number | null; +} + function userInitials(displayName: string, username: string): string { const source = displayName.trim() || username; const parts = source.split(/[\s._-]+/).filter(Boolean); @@ -206,6 +245,7 @@ function policyPayload( ): { workspace_root_dir: string | null; token_quota: number | null; + max_agents: number | null; } { return { workspace_root_dir: @@ -213,9 +253,133 @@ function policyPayload( ? values.workspace_root_dir?.trim() || null : null, token_quota: values.limit_token_quota ? values.token_quota ?? null : null, + max_agents: values.limit_max_agents ? values.max_agents ?? null : null, }; } +/** Common quotas admins pick — values are absolute token counts. */ +const TOKEN_QUOTA_PRESETS = [ + 1_000_000, 5_000_000, 10_000_000, 50_000_000, 100_000_000, +] as const; + +const MAX_AGENTS_PRESETS = [1, 3, 5, 10, 20] as const; + +function formatMillionsLabel(tokens: number): string { + const millions = tokens / 1_000_000; + if (Number.isInteger(millions)) return String(millions); + return millions.toFixed(2).replace(/\.?0+$/, ""); +} + +function formatTokenQuotaExact(tokens: number): string { + return tokens.toLocaleString("en-US"); +} + +interface TokenQuotaInputProps { + value?: number | null; + onChange?: (value: number | null) => void; +} + +function TokenQuotaInput({ value, onChange }: TokenQuotaInputProps) { + const { t } = useTranslation(); + const numeric = + typeof value === "number" && Number.isFinite(value) ? value : null; + + return ( +
+ onChange?.(typeof next === "number" ? next : null)} + min={0} + step={1_000_000} + style={{ width: "100%" }} + placeholder={t("adminUsers.policyTokenQuotaPlaceholder")} + formatter={(raw) => + `${raw ?? ""}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",") + } + parser={(raw) => { + const cleaned = (raw ?? "").replace(/,/g, ""); + if (!cleaned) return undefined as unknown as number; + return Number(cleaned); + }} + /> +
+ + {t("adminUsers.policyTokenQuotaPresets")} + + {TOKEN_QUOTA_PRESETS.map((preset) => { + const selected = numeric === preset; + return ( + + ); + })} +
+ {numeric != null && numeric > 0 ? ( +
+ {t("adminUsers.policyTokenQuotaPreview", { + millions: formatMillionsLabel(numeric), + exact: formatTokenQuotaExact(numeric), + })} +
+ ) : null} +
+ ); +} + +interface MaxAgentsInputProps { + value?: number | null; + onChange?: (value: number | null) => void; +} + +function MaxAgentsInput({ value, onChange }: MaxAgentsInputProps) { + const { t } = useTranslation(); + const numeric = + typeof value === "number" && Number.isFinite(value) ? value : null; + + return ( +
+ onChange?.(typeof next === "number" ? next : null)} + min={0} + step={1} + style={{ width: "100%" }} + placeholder={t("adminUsers.policyMaxAgentsPlaceholder")} + /> +
+ + {t("common.tokenCountPresets")} + + {MAX_AGENTS_PRESETS.map((preset) => { + const selected = numeric === preset; + return ( + + ); + })} +
+
+ ); +} + function ResourcePolicyFields({ fsTreeRoot, workspaceRootAllowed, @@ -293,7 +457,37 @@ function ResourcePolicyFields({ }, ]} > - + + + ) : null + } + + + + + + + + prev.limit_max_agents !== cur.limit_max_agents + } + > + {({ getFieldValue }) => + getFieldValue("limit_max_agents") ? ( + + ) : null } @@ -581,6 +775,8 @@ function UserCardGrid({ agentsLoading, currentUserId, permLabelByKey, + selectedIds, + onToggleSelect, onTogglePatch, onEdit, onShowAgents, @@ -591,6 +787,7 @@ function UserCardGrid({ }: UserCardGridProps) { const { t } = useTranslation(); const timeZone = useServerTimezone(); + const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); if (loading && rows.length === 0) { return (
@@ -606,6 +803,7 @@ function UserCardGrid({ {rows.map((row) => { const agentCount = agentsByUserId.get(row.id)?.length ?? 0; const isSelf = row.id === currentUserId; + const selected = selectedSet.has(row.id); const displayName = row.display_name?.trim() || row.username; const remaining = lockRemainingSeconds(row, nowSec); const isLocked = remaining > 0; @@ -626,6 +824,7 @@ function UserCardGrid({ key={row.id} className={[ styles.userCard, + selected ? styles.userCardSelected : "", isLocked ? styles.userCardLocked : "", row.disabled ? styles.userCardDisabled : "", ] @@ -636,6 +835,14 @@ function UserCardGrid({
+ onToggleSelect(row.id, e.target.checked)} + className={styles.userCardSelect} + aria-label={t("adminUsers.batchSelectUser", { + username: row.username, + })} + />
(null); const [editAgent, setEditAgent] = useState(null); const [searchQuery, setSearchQuery] = useState(""); + const [selectedIds, setSelectedIds] = useState([]); + const [batchSubmitting, setBatchSubmitting] = useState(false); + const [batchTokenOpen, setBatchTokenOpen] = useState(false); + const [batchTokenForm] = Form.useForm(); + const [batchMaxAgentsOpen, setBatchMaxAgentsOpen] = useState(false); + const [batchMaxAgentsForm] = Form.useForm(); const { viewMode, setViewMode, showCardView } = useCardTableView("table"); const [permCatalog, setPermCatalog] = useState([]); const [fsTreeRoot, setFsTreeRoot] = useState(HOST_FS_ROOT); @@ -977,6 +1190,39 @@ export default function UsersListPanel() { }); }, [rows, searchQuery]); + const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]); + const selectedVisibleCount = useMemo( + () => filteredRows.filter((row) => selectedIdSet.has(row.id)).length, + [filteredRows, selectedIdSet], + ); + + const clearSelection = useCallback(() => setSelectedIds([]), []); + + const toggleSelectOne = useCallback((id: number, checked: boolean) => { + setSelectedIds((prev) => { + if (checked) { + return prev.includes(id) ? prev : [...prev, id]; + } + return prev.filter((item) => item !== id); + }); + }, []); + + const setSelectAllVisible = useCallback( + (checked: boolean) => { + const visibleIds = filteredRows.map((row) => row.id); + setSelectedIds((prev) => { + if (checked) { + const next = new Set(prev); + for (const id of visibleIds) next.add(id); + return Array.from(next); + } + const drop = new Set(visibleIds); + return prev.filter((id) => !drop.has(id)); + }); + }, + [filteredRows], + ); + const refreshUsers = useCallback(async () => { setLoading(true); try { @@ -991,6 +1237,143 @@ export default function UsersListPanel() { } }, [t]); + const reportBatchResult = useCallback( + (body: BatchResponse) => { + if (body.failed === 0) { + message.success( + t("adminUsers.batchSuccess", { + count: body.succeeded, + action: t(`adminUsers.batchAction.${body.action}`), + }), + ); + return; + } + if (body.succeeded === 0) { + message.error( + t("adminUsers.batchAllFailed", { + failed: body.failed, + action: t(`adminUsers.batchAction.${body.action}`), + }), + ); + return; + } + message.warning( + t("adminUsers.batchPartial", { + succeeded: body.succeeded, + failed: body.failed, + action: t(`adminUsers.batchAction.${body.action}`), + }), + ); + }, + [t], + ); + + const runBatch = useCallback( + async ( + action: BatchAction, + options?: { + token_quota?: number | null; + max_agents?: number | null; + }, + ) => { + if (selectedIds.length === 0) return; + setBatchSubmitting(true); + try { + const body = await request("/users/batch", { + method: "POST", + body: JSON.stringify({ + user_ids: selectedIds, + action, + ...(action === "set_token_quota" + ? { token_quota: options?.token_quota ?? null } + : {}), + ...(action === "set_max_agents" + ? { max_agents: options?.max_agents ?? null } + : {}), + }), + }); + reportBatchResult(body); + clearSelection(); + setBatchTokenOpen(false); + batchTokenForm.resetFields(); + setBatchMaxAgentsOpen(false); + batchMaxAgentsForm.resetFields(); + void refreshUsers(); + } catch (err) { + message.error( + err instanceof Error ? err.message : t("adminUsers.batchFailed"), + ); + } finally { + setBatchSubmitting(false); + } + }, + [ + selectedIds, + reportBatchResult, + clearSelection, + batchTokenForm, + batchMaxAgentsForm, + refreshUsers, + t, + ], + ); + + const confirmBatchAction = ( + action: Extract, + ) => { + if (selectedIds.length === 0) return; + const titles = { + enable: "adminUsers.batchEnableConfirm", + disable: "adminUsers.batchDisableConfirm", + delete: "adminUsers.batchDeleteConfirm", + } as const; + const hints = { + enable: "adminUsers.batchEnableHint", + disable: "adminUsers.batchDisableHint", + delete: "adminUsers.batchDeleteHint", + } as const; + const okLabels = { + enable: t("adminUsers.batchEnable"), + disable: t("adminUsers.batchDisable"), + delete: t("common.delete"), + } as const; + Modal.confirm({ + title: t(titles[action], { count: selectedIds.length }), + content: t(hints[action]), + okType: action === "delete" ? "danger" : "primary", + okText: okLabels[action], + cancelText: t("common.cancel"), + onOk: () => runBatch(action), + }); + }; + + const openBatchToken = () => { + batchTokenForm.setFieldsValue({ + limit_token_quota: true, + token_quota: 10_000_000, + }); + setBatchTokenOpen(true); + }; + + const submitBatchToken = async (values: BatchTokenFormValues) => { + await runBatch("set_token_quota", { + token_quota: values.limit_token_quota ? values.token_quota ?? null : null, + }); + }; + + const openBatchMaxAgents = () => { + batchMaxAgentsForm.setFieldsValue({ + limit_max_agents: true, + max_agents: 5, + }); + setBatchMaxAgentsOpen(true); + }; + + const submitBatchMaxAgents = async (values: BatchMaxAgentsFormValues) => { + await runBatch("set_max_agents", { + max_agents: values.limit_max_agents ? values.max_agents ?? null : null, + }); + }; useEffect(() => { if (!hasLockedUser) return; const anyExpired = rows.some( @@ -1123,6 +1506,8 @@ export default function UsersListPanel() { workspace_root_dir: undefined, limit_token_quota: false, token_quota: undefined, + limit_max_agents: false, + max_agents: undefined, }); setCreateOpen(true); }; @@ -1142,6 +1527,8 @@ export default function UsersListPanel() { : undefined, limit_token_quota: row.token_quota != null, token_quota: row.token_quota ?? undefined, + limit_max_agents: row.max_agents != null, + max_agents: row.max_agents ?? undefined, }); }; @@ -1247,14 +1634,32 @@ export default function UsersListPanel() {
- } - value={searchQuery} - onChange={(event) => setSearchQuery(event.target.value)} - placeholder={t("adminUsers.searchPlaceholder")} - className={styles.userSearch} - /> +
+ {showCardView ? ( + 0 && + selectedVisibleCount === filteredRows.length + } + indeterminate={ + selectedVisibleCount > 0 && + selectedVisibleCount < filteredRows.length + } + disabled={filteredRows.length === 0} + onChange={(e) => setSelectAllVisible(e.target.checked)} + > + {t("adminUsers.batchSelectAll")} + + ) : null} + } + value={searchQuery} + onChange={(event) => setSearchQuery(event.target.value)} + placeholder={t("adminUsers.searchPlaceholder")} + className={styles.userSearch} + /> +
+ {selectedIds.length > 0 ? ( +
+ + {t("adminUsers.batchSelected", { count: selectedIds.length })} + +
+ + + + + + +
+
+ ) : null} + {showCardView ? ( setSelectedIds(keys.map((key) => Number(key))), + preserveSelectedRowKeys: true, + }} rowClassName={(row) => [ row.disabled ? styles.userTableRowDisabled : "", @@ -1632,6 +2104,7 @@ export default function UsersListPanel() { permissions: [], limit_workspace_root: false, limit_token_quota: false, + limit_max_agents: false, }} className={styles.createUserForm} > @@ -1972,6 +2445,118 @@ export default function UsersListPanel() { + + { + setBatchTokenOpen(false); + batchTokenForm.resetFields(); + }} + onOk={() => batchTokenForm.submit()} + okText={t("common.confirm")} + cancelText={t("common.cancel")} + confirmLoading={batchSubmitting} + destroyOnHidden + > + + {t("adminUsers.batchSetTokenHint")} + + + form={batchTokenForm} + layout="vertical" + onFinish={(values) => void submitBatchToken(values)} + > + + + + + + + prev.limit_token_quota !== cur.limit_token_quota + } + > + {({ getFieldValue }) => + getFieldValue("limit_token_quota") ? ( + + + + ) : null + } + + + + + { + setBatchMaxAgentsOpen(false); + batchMaxAgentsForm.resetFields(); + }} + onOk={() => batchMaxAgentsForm.submit()} + okText={t("common.confirm")} + cancelText={t("common.cancel")} + confirmLoading={batchSubmitting} + destroyOnHidden + > + + {t("adminUsers.batchSetMaxAgentsHint")} + + + form={batchMaxAgentsForm} + layout="vertical" + onFinish={(values) => void submitBatchMaxAgents(values)} + > + + + + + + + prev.limit_max_agents !== cur.limit_max_agents + } + > + {({ getFieldValue }) => + getFieldValue("limit_max_agents") ? ( + + + + ) : null + } + + + ); } diff --git a/dashboard/src/pages/Admin/Users/index.module.less b/dashboard/src/pages/Admin/Users/index.module.less index 86fb4409a9..cbfbe1a71d 100644 --- a/dashboard/src/pages/Admin/Users/index.module.less +++ b/dashboard/src/pages/Admin/Users/index.module.less @@ -83,6 +83,58 @@ margin-bottom: 12px !important; } +.tokenQuotaField { + display: flex; + flex-direction: column; + gap: 8px; +} + +.tokenQuotaPresets { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.tokenQuotaPresetsLabel { + font-size: 12px; + color: var(--fn-text-tertiary); + margin-right: 2px; +} + +.tokenQuotaPreset { + appearance: none; + border: 1px solid var(--fn-border-primary); + background: var(--fn-bg-primary); + color: var(--fn-text-secondary); + border-radius: 6px; + padding: 2px 8px; + font-size: 12px; + line-height: 20px; + cursor: pointer; + transition: + border-color 0.15s ease, + color 0.15s ease, + background 0.15s ease; + + &:hover { + border-color: var(--fn-primary, #1677ff); + color: var(--fn-primary, #1677ff); + } +} + +.tokenQuotaPresetActive { + border-color: var(--fn-primary, #1677ff); + color: var(--fn-primary, #1677ff); + background: color-mix(in srgb, var(--fn-primary, #1677ff) 10%, transparent); +} + +.tokenQuotaPreview { + font-size: 12px; + line-height: 1.5; + color: var(--fn-text-tertiary); +} + .createUserPermItem { margin-bottom: 0 !important; } @@ -388,6 +440,44 @@ width: min(320px, 100%); } +.userSearchRow { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; +} + +.batchBar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px 16px; + margin: -4px 0 14px; + padding: 10px 12px; + border: 1px solid var(--fn-border-primary); + border-radius: var(--fn-radius-md, 8px); + background: color-mix( + in srgb, + var(--fn-primary, #1677ff) 6%, + var(--fn-bg-primary) + ); +} + +.batchBarCount { + font-size: 13px; + font-weight: 600; + color: var(--fn-text-primary); +} + +.batchBarActions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + /* Role legend: label + body, same type scale / line-height for alignment */ .roleLegend { display: grid; @@ -449,6 +539,17 @@ } } +.userCardSelected { + border-color: var(--fn-primary, #1677ff); + box-shadow: 0 0 0 1px + color-mix(in srgb, var(--fn-primary, #1677ff) 35%, transparent); +} + +.userCardSelect { + flex-shrink: 0; + margin-top: 4px; +} + .userCardLocked { border-color: rgba(255, 77, 79, 0.3); } diff --git a/dashboard/src/pages/Experts/components/AgentBackendFields.tsx b/dashboard/src/pages/Experts/components/AgentBackendFields.tsx index 5da9cae2f4..7b24ccdc55 100644 --- a/dashboard/src/pages/Experts/components/AgentBackendFields.tsx +++ b/dashboard/src/pages/Experts/components/AgentBackendFields.tsx @@ -22,7 +22,7 @@ interface AgentBackendFieldsProps { backendsLoading: boolean; backendChoice: string; pathMappings: PathMapping[]; - /** ``create`` fills empty root_dir with home; ``edit`` leaves existing values. */ + /** ``create`` fills empty root_dir with the filesystem default; ``edit`` leaves existing values. */ rootDirMode?: "create" | "edit"; disabled?: boolean; onAddPathMapping: () => void; @@ -197,9 +197,7 @@ export default function AgentBackendFields({ > {fsDefaults?.in_container ? t("experts.backendRootDirDescContainer") - : t("experts.backendRootDirDesc", { - home: fsDefaults?.home ?? "~", - })} + : t("experts.backendRootDirDesc")}

{ expect(normalizeRootDir("/tmp/project/")).toBe("/tmp/project"); expect(isHostRootDir("/")).toBe(true); expect(isHostRootDir("/tmp/project")).toBe(false); + expect(isHostRootDir("C:/")).toBe(true); + expect(isHostRootDir("C:\\")).toBe(true); + expect(isHostRootDir("D:")).toBe(true); + expect(isHostRootDir("C:/Users")).toBe(false); }); it("allows local backends with host root", () => { diff --git a/dashboard/src/pages/Experts/components/agentBackendForm.ts b/dashboard/src/pages/Experts/components/agentBackendForm.ts index eb22ec5a10..65bc7ca6b0 100644 --- a/dashboard/src/pages/Experts/components/agentBackendForm.ts +++ b/dashboard/src/pages/Experts/components/agentBackendForm.ts @@ -19,9 +19,7 @@ export interface BackendOption { } export interface FilesystemDefaults { - home: string; default_root_dir: string; - allow_outside_home: boolean; tree_root: string; /** True when the Octop server process runs inside a container. */ in_container?: boolean; @@ -79,11 +77,16 @@ export interface RootDirProbeResult { export function normalizeRootDir(rootDir?: string | null): string { const trimmed = (rootDir ?? "").trim(); if (!trimmed || trimmed === "\\" || trimmed === "/") return "/"; - return trimmed.replace(/\/+$/, "") || "/"; + // Strip trailing POSIX or Windows separators (so ``C:/`` → ``C:``). + const stripped = trimmed.replace(/[/\\]+$/, ""); + return stripped || "/"; } +/** True for POSIX ``/`` or a Windows drive root such as ``C:/`` / ``C:``. */ export function isHostRootDir(rootDir?: string | null): boolean { - return normalizeRootDir(rootDir) === "/"; + const normalized = normalizeRootDir(rootDir); + if (normalized === "/") return true; + return /^[A-Za-z]:$/.test(normalized); } /** diff --git a/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx b/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx index fb75210ed7..67c7aef04b 100644 --- a/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx +++ b/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx @@ -5,16 +5,7 @@ * Connectivity tests still hit the live provider endpoint. */ import { useMemo, useState } from "react"; -import { - App, - Button, - Form, - Input, - InputNumber, - Select, - Switch, - Tooltip, -} from "antd"; +import { App, Button, Form, Input, Select, Switch, Tooltip } from "antd"; import { Check, @@ -34,8 +25,19 @@ import type { ProviderRow, ProviderModel } from "../../useProviders"; import { isEmbeddingModel } from "../../useProviders"; import { isOnnxProviderRow } from "../../presetUtils"; import { ModelMetaTags } from "../../modelMeta"; +import { TokenCountInput } from "@/components/TokenCountInput"; import styles from "../../index.module.less"; +/** Common context-window sizes (absolute tokens). */ +const CONTEXT_WINDOW_PRESETS = [ + 8_000, 32_000, 64_000, 128_000, 200_000, 1_000_000, +] as const; + +/** Common max-output sizes (absolute tokens). */ +const MAX_TOKENS_PRESETS = [ + 4_000, 8_000, 16_000, 32_000, 64_000, 128_000, +] as const; + export interface LocalModelDownloadControl { /** Model ids already present on disk / in the local runtime. */ downloadedIds: ReadonlySet | readonly string[]; @@ -618,9 +620,8 @@ export function ModelListEditor({ label={t("models.contextWindow")} style={{ flex: 1, marginBottom: 10 }} > - @@ -629,9 +630,8 @@ export function ModelListEditor({ label={t("models.maxTokens")} style={{ flex: 1, marginBottom: 10 }} > - diff --git a/src/octop/api/routers/filesystem.py b/src/octop/api/routers/filesystem.py index b497436f73..378b6e2126 100644 --- a/src/octop/api/routers/filesystem.py +++ b/src/octop/api/routers/filesystem.py @@ -8,8 +8,8 @@ ``/etc``, ``/root`` on POSIX). The process home is never denied (so uid 0 with home ``/root`` can use the default picker path). - All authenticated users may browse from host root ``/`` (denylist still applies). - The UI default ``root_dir`` is the process home on bare metal, or host ``/`` - when Octop runs inside a container (override with ``OCTOP_IN_CONTAINER``). + The UI default ``root_dir`` is host filesystem root (POSIX ``/``), unless the + user has a ``workspace_root_dir`` policy jail. - Directory listing is capped and skips unreadable entries. - Write probe creates a short-lived dotfile only for non-``/`` selections. - mkdir / rename only allow basename-safe names under already-browsable parents. @@ -34,9 +34,7 @@ from octop.infra.utils.docker_env import docker_status, ensure_docker from octop.infra.utils.host_dirs import ( assert_safe_host_path, - default_host_root_dir, host_fs_tree_root, - host_home_dir, host_path_text, list_host_subdirs, mkdir_host_subdir, @@ -79,27 +77,23 @@ async def filesystem_defaults( user: User = Depends(current_user), server: Any = Depends(get_server), ) -> dict[str, Any]: - """Return the process home path and browse-tree root (host ``/`` on POSIX). + """Return browse-tree / default root_dir for the current user. - When Octop runs inside a container, ``default_root_dir`` is filesystem - root (not home) unless the user has a workspace-root policy jail. + Unrestricted users get filesystem root; a workspace-root policy jail sets + both ``default_root_dir`` and ``tree_root`` to that path. """ - home = host_path_text(host_home_dir()) in_container = running_in_container() allowed = _user_workspace_root(server, user) if allowed: return { - "home": home, "default_root_dir": allowed, - "allow_outside_home": False, "tree_root": allowed, "in_container": in_container, } + root = host_fs_tree_root() return { - "home": home, - "default_root_dir": default_host_root_dir(allow_outside_home=True), - "allow_outside_home": True, - "tree_root": host_fs_tree_root(allow_outside_home=True), + "default_root_dir": root, + "tree_root": root, "in_container": in_container, } diff --git a/src/octop/api/routers/setup.py b/src/octop/api/routers/setup.py index 8a7f7ddaa3..08eaf59642 100644 --- a/src/octop/api/routers/setup.py +++ b/src/octop/api/routers/setup.py @@ -157,6 +157,7 @@ async def _bootstrap_default_agent(server: Any, *, user_id: int, locale: str = " from octop.infra.agents.default_agent import ( SETUP_DEFAULT_AGENT_ID, bootstrap_default_agent, + user_policy_workspace_root, ) if server.app_runtime is None: @@ -167,6 +168,7 @@ async def _bootstrap_default_agent(server: Any, *, user_id: int, locale: str = " user_id=user_id, locale=locale, agent_id=SETUP_DEFAULT_AGENT_ID, + root_dir=user_policy_workspace_root(server, user_id), ) diff --git a/src/octop/api/routers/users.py b/src/octop/api/routers/users.py index a335e3eb4e..f5d40c14fa 100644 --- a/src/octop/api/routers/users.py +++ b/src/octop/api/routers/users.py @@ -3,7 +3,7 @@ from __future__ import annotations import time -from typing import Any +from typing import Any, Literal from fastapi import APIRouter, Depends, Request from pydantic import BaseModel, Field @@ -13,6 +13,7 @@ from octop.infra.users.identity import Role, User from octop.infra.users.permissions import PERMISSIONS from octop.infra.users.resource_policy import ( + normalize_max_agents, normalize_token_quota, normalize_workspace_root_dir, public_policy_fields, @@ -21,6 +22,8 @@ router = APIRouter() +UserBatchAction = Literal["enable", "disable", "delete", "set_token_quota", "set_max_agents"] + class UserCreateBody(BaseModel): username: str = Field(min_length=1, max_length=64) @@ -31,6 +34,7 @@ class UserCreateBody(BaseModel): permissions: list[str] = Field(default_factory=list) workspace_root_dir: str | None = None token_quota: int | None = Field(default=None, ge=0) + max_agents: int | None = Field(default=None, ge=0) class UserPatchBody(BaseModel): @@ -41,12 +45,42 @@ class UserPatchBody(BaseModel): permissions: list[str] | None = None workspace_root_dir: str | None = None token_quota: int | None = Field(default=None, ge=0) + max_agents: int | None = Field(default=None, ge=0) class ResetPasswordBody(BaseModel): new_password: str = Field(min_length=1, max_length=200) +class UserBatchBody(BaseModel): + user_ids: list[int] = Field(min_length=1, max_length=500) + action: UserBatchAction + token_quota: int | None = Field( + default=None, + ge=0, + description="Used by set_token_quota. null clears the limit (unlimited).", + ) + max_agents: int | None = Field( + default=None, + ge=0, + description="Used by set_max_agents. null clears the limit (unlimited).", + ) + + +class UserBatchItemResult(BaseModel): + user_id: int + ok: bool + error: str | None = None + code: str | None = None + + +class UserBatchResponse(BaseModel): + action: UserBatchAction + results: list[UserBatchItemResult] + succeeded: int + failed: int + + def _row_to_dict(r: Any, policy: Any | None = None) -> dict[str, Any]: now = int(time.time()) locked_until = int(getattr(r, "login_locked_until", 0) or 0) @@ -77,6 +111,8 @@ def _policy_kwargs_from_body(body: UserCreateBody | UserPatchBody) -> dict[str, policy_kwargs["workspace_root_dir"] = body.workspace_root_dir if "token_quota" in body.model_fields_set: policy_kwargs["token_quota"] = body.token_quota + if "max_agents" in body.model_fields_set: + policy_kwargs["max_agents"] = body.max_agents return policy_kwargs @@ -168,6 +204,8 @@ async def create_user( normalize_workspace_root_dir(policy_kwargs["workspace_root_dir"]) if "token_quota" in policy_kwargs: normalize_token_quota(policy_kwargs["token_quota"]) + if "max_agents" in policy_kwargs: + normalize_max_agents(policy_kwargs["max_agents"]) role = Role(body.role) user = await server.user_manager.create( username=body.username, @@ -184,6 +222,114 @@ async def create_user( return _row_to_dict(row, server.services.user_policy_repo.list_for_user(row.id)) +def _batch_fail(user_id: int, *, code: str, error: str) -> UserBatchItemResult: + return UserBatchItemResult(user_id=user_id, ok=False, code=code, error=error) + + +async def _batch_apply_one( + *, + server: Any, + actor: User, + user_id: int, + action: UserBatchAction, + token_quota: int | None, + max_agents: int | None, +) -> UserBatchItemResult: + if action in ("disable", "delete") and user_id == actor.id: + return _batch_fail( + user_id, + code=ErrorCode.FORBIDDEN.value, + error="cannot apply this action to yourself", + ) + row = server.user_manager.get_row(user_id) + if row is None: + return _batch_fail( + user_id, + code=ErrorCode.NOT_FOUND.value, + error="user not found", + ) + try: + if action == "enable": + await server.user_manager.enable(row.username) + elif action == "disable": + await server.user_manager.disable(row.username) + elif action == "delete": + await server.user_manager.remove(row.username) + elif action == "set_token_quota": + await server.user_manager.set_resource_policy( + row.username, + token_quota=token_quota, + ) + elif action == "set_max_agents": + await server.user_manager.set_resource_policy( + row.username, + max_agents=max_agents, + ) + else: + return _batch_fail( + user_id, + code=ErrorCode.SLASH_BAD_ARGS.value, + error=f"unknown action: {action}", + ) + except OctopError as exc: + return _batch_fail( + user_id, + code=exc.code.value, + error=exc.message, + ) + return UserBatchItemResult(user_id=user_id, ok=True) + + +@router.post( + "/batch", + summary="Batch enable, disable, delete, or set resource policies", + description=( + "Apply one action to many users. Per-user failures do not abort the batch. " + "Disable and delete skip the current actor. For set_token_quota / set_max_agents, " + "a null value clears the limit." + ), + response_model=UserBatchResponse, +) +async def batch_users( + body: UserBatchBody, + actor: Any = Depends(require_permission("users")), + server: Any = Depends(get_server), +) -> UserBatchResponse: + if body.action == "set_token_quota": + normalize_token_quota(body.token_quota) + if body.action == "set_max_agents": + normalize_max_agents(body.max_agents) + + # Preserve order while dropping duplicates. + seen: set[int] = set() + user_ids: list[int] = [] + for uid in body.user_ids: + if uid in seen: + continue + seen.add(uid) + user_ids.append(uid) + + results: list[UserBatchItemResult] = [] + for user_id in user_ids: + results.append( + await _batch_apply_one( + server=server, + actor=actor, + user_id=user_id, + action=body.action, + token_quota=body.token_quota, + max_agents=body.max_agents, + ) + ) + succeeded = sum(1 for item in results if item.ok) + return UserBatchResponse( + action=body.action, + results=results, + succeeded=succeeded, + failed=len(results) - succeeded, + ) + + @router.get("/{user_id}") async def get_user( user_id: int, diff --git a/src/octop/i18n/domains/stream.py b/src/octop/i18n/domains/stream.py index 3e84205df4..20ae4f12ce 100644 --- a/src/octop/i18n/domains/stream.py +++ b/src/octop/i18n/domains/stream.py @@ -16,12 +16,14 @@ TIMEOUT_NETWORK = f"{_PREFIX}stream_errors.timeout_network" PROVIDER_UNAVAILABLE = f"{_PREFIX}stream_errors.provider_unavailable" MODEL_CALL_FAILED = f"{_PREFIX}stream_errors.model_call_failed" +PATH_OUTSIDE_ROOT = f"{_PREFIX}stream_errors.path_outside_root" __all__ = [ "AUTH", "CONTEXT_LENGTH", "INSUFFICIENT_BALANCE", "MODEL_CALL_FAILED", + "PATH_OUTSIDE_ROOT", "PROVIDER_UNAVAILABLE", "RATE_LIMIT", "RECURSION_LIMIT", @@ -63,6 +65,10 @@ def classify_stream_error_message(message: str) -> str | None: lower = msg.lower() compact = lower.replace("_", "").replace(" ", "") + # Tool / backend path jail — must not look like a model-provider outage. + if "outside root directory" in lower or "path traversal not allowed" in lower: + return PATH_OUTSIDE_ROOT + if ( "streamchunktimeouterror" in compact or "no streaming chunk received" in lower diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index 7ce561d60b..0cc4fdf184 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -437,6 +437,7 @@ "WORKSPACE_ROOT_RESTRICTED": "The selected storage root is outside the allowed directory for this account.", "WORKSPACE_ROOT_CONTAINER_UNSUPPORTED": "Allowed storage root cannot be set while Octop is running in a container.", "TOKEN_QUOTA_EXCEEDED": "This account has reached its token quota. Please contact an administrator to raise the limit ({used}/{quota}).", + "AGENT_QUOTA_EXCEEDED": "This account has reached its expert limit. Please contact an administrator to raise the limit ({used}/{quota}).", "CAPTCHA_REQUIRED": "This server requires a captcha token to sign in.", "CAPTCHA_FAILED": "Captcha verification failed. Try again.", "CONFIG_FILE_CORRUPT": "Cannot update {path}: the file is not valid JSON ({detail}). Fix the syntax and retry — no settings were changed.", @@ -646,7 +647,8 @@ "recursion_limit": "The agent hit its max iteration / recursion limit before finishing. Open Configuration and raise Max Iterations, then try again.", "timeout_network": "Connecting to the model timed out or the network failed. Check your network and retry. If you use a proxy or self-hosted endpoint, confirm it is reachable.", "provider_unavailable": "The model provider is temporarily unavailable. Retry later, or switch to another model.", - "model_call_failed": "The model call failed after several retries. Wait a moment and retry. If it keeps failing, check the model settings or switch models." + "model_call_failed": "The model call failed after several retries. Wait a moment and retry. If it keeps failing, check the model settings or switch models.", + "path_outside_root": "The file path is outside this expert's storage root, so it cannot be read or written. Use a path under the storage root, or widen the storage root in expert settings (on Windows, set an explicit drive path such as D:/octop-data)." }, "attachment": { "header": "[Attachment] {filename}", diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index 19a83e8b61..31174a8f25 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -437,6 +437,7 @@ "WORKSPACE_ROOT_RESTRICTED": "所选存储根目录超出了该账号允许的范围。", "WORKSPACE_ROOT_CONTAINER_UNSUPPORTED": "容器环境下不能设置「允许的存储根目录」。", "TOKEN_QUOTA_EXCEEDED": "该账号已达到 token 配额上限,请联系管理员提升额度({used}/{quota})。", + "AGENT_QUOTA_EXCEEDED": "该账号已达到专家数量上限,请联系管理员提升额度({used}/{quota})。", "CAPTCHA_REQUIRED": "此服务器登录需要验证码令牌。", "CAPTCHA_FAILED": "验证码校验失败,请重试。", "CONFIG_FILE_CORRUPT": "无法更新 {path}:该文件不是合法 JSON({detail})。请修正语法后重试;现有配置未被改动。", @@ -646,7 +647,8 @@ "recursion_limit": "智能体已达到最大迭代次数(递归上限),任务尚未完成。请前往「运行配置」调高「最大迭代次数」后重试。", "timeout_network": "连接模型服务超时或网络异常。请检查网络后重试;若使用代理或自建服务,请确认其可达。", "provider_unavailable": "模型服务暂时不可用。请稍后重试,或切换其他模型。", - "model_call_failed": "模型调用多次重试后仍失败。请稍后重试;若持续失败,请检查模型配置或更换模型。" + "model_call_failed": "模型调用多次重试后仍失败。请稍后重试;若持续失败,请检查模型配置或更换模型。", + "path_outside_root": "文件路径超出当前专家的存储根目录,无法读写。请改用存储根目录内的路径,或在专家设置中扩大存储根目录(Windows 上请显式指定盘符路径,例如 D:/octop-data)。" }, "attachment": { "header": "[附件] {filename}", diff --git a/src/octop/infra/agents/default_agent.py b/src/octop/infra/agents/default_agent.py index c281011025..f26f3e6764 100644 --- a/src/octop/infra/agents/default_agent.py +++ b/src/octop/infra/agents/default_agent.py @@ -11,7 +11,11 @@ from octop.infra.agents.experts.catalog import ExpertCatalog, build_create_spec_from_expert from octop.infra.errors import ErrorCode, OctopError -from octop.infra.utils.host_dirs import host_home_dir, host_path_text +from octop.infra.users.resource_policy import ( + POLICY_WORKSPACE_ROOT_DIR, + effective_workspace_root_dir, +) +from octop.infra.utils.host_dirs import host_fs_tree_root from octop.infra.utils.locale import normalize_locale logger = logging.getLogger(__name__) @@ -20,15 +24,27 @@ SETUP_DEFAULT_AGENT_ID = "main" -def default_home_local_backend() -> dict[str, Any]: - """Same local backend as the dashboard create-from-expert default (home-scoped).""" +def default_home_local_backend(*, root_dir: str | None = None) -> dict[str, Any]: + """Same local backend as the dashboard create-from-expert default. + + Defaults to host filesystem root; pass *root_dir* when a user + ``workspace_root_dir`` policy applies. + """ + resolved = (root_dir or "").strip() or host_fs_tree_root() return { "type": "local_shell", - "root_dir": host_path_text(host_home_dir()), + "root_dir": resolved, "virtual_mode": True, } +def user_policy_workspace_root(server: Any, user_id: int) -> str | None: + """Effective ``workspace_root_dir`` policy for *user_id*, or ``None``.""" + return effective_workspace_root_dir( + server.services.user_policy_repo.get(user_id, POLICY_WORKSPACE_ROOT_DIR) + ) + + async def bootstrap_default_agent( registry: Any, catalog: ExpertCatalog | None, @@ -36,6 +52,7 @@ async def bootstrap_default_agent( user_id: int, locale: str = "zh", agent_id: str | None = None, + root_dir: str | None = None, ) -> Any | None: """Create ``general-assistant`` for *user_id* when they have no agents yet. @@ -61,7 +78,7 @@ async def bootstrap_default_agent( user_id=user_id, agent_id=agent_id, locale=loc, - config_extra={"backend": default_home_local_backend()}, + config_extra={"backend": default_home_local_backend(root_dir=root_dir)}, ) return await registry.create(spec, defer_bootstrap=True) @@ -84,6 +101,7 @@ async def try_bootstrap_default_agent( user_id=user_id, locale=locale, agent_id=agent_id, + root_dir=user_policy_workspace_root(server, user_id), ) except Exception as exc: # pragma: no cover - logged; user creation should still succeed logger.warning("could not auto-create default agent for user %s: %s", user_id, exc) diff --git a/src/octop/infra/agents/manager.py b/src/octop/infra/agents/manager.py index b5cf3eb4b9..80e865b924 100644 --- a/src/octop/infra/agents/manager.py +++ b/src/octop/infra/agents/manager.py @@ -578,9 +578,19 @@ async def create( DEFAULT_SYSTEM_FILES_PATH, seed_workspace_dir_on_create, ) - from octop.infra.users.resource_policy import raise_if_backend_outside_user_root + from octop.infra.users.resource_policy import ( + assert_agent_quota_available, + raise_if_backend_outside_user_root, + ) if spec.user_id is not None: + kind = spec.kind if spec.kind in {"expert", "team"} else "expert" + if kind == "expert": + assert_agent_quota_available( + self._repos.user_policy_repo, + self._repos.agent_repo, + spec.user_id, + ) raise_if_backend_outside_user_root( self._repos.user_policy_repo, spec.user_id, diff --git a/src/octop/infra/agents/middleware/thread_artifacts.py b/src/octop/infra/agents/middleware/thread_artifacts.py index cbf49e9cdb..17d4566dc8 100644 --- a/src/octop/infra/agents/middleware/thread_artifacts.py +++ b/src/octop/infra/agents/middleware/thread_artifacts.py @@ -21,6 +21,7 @@ file_url_to_abs_path, is_host_absolute_path, ) +from octop.infra.gateway.media.tool_media import tool_name_base logger = logging.getLogger(__name__) @@ -31,6 +32,7 @@ "send_file", "send_file_to_user", "desktop_screenshot", + "mobile_screenshot", } ) @@ -55,12 +57,6 @@ class ArtifactThreadStore(Protocol): def append_artifacts(self, thread_id: str, paths: Sequence[str]) -> None: ... -def tool_name_base(name: str) -> str: - trimmed = (name or "").strip() - slash = trimmed.rfind("/") - return trimmed[slash + 1 :] if slash >= 0 else trimmed - - def is_artifact_tool_name(name: str | None) -> bool: base = tool_name_base(name or "").lower() return base in ARTIFACT_TOOL_BASES diff --git a/src/octop/infra/agents/workspace_dir.py b/src/octop/infra/agents/workspace_dir.py index e0dd456ed2..5b94ff02ad 100644 --- a/src/octop/infra/agents/workspace_dir.py +++ b/src/octop/infra/agents/workspace_dir.py @@ -36,6 +36,8 @@ SCOPED_WORKSPACE_DIRNAME = "workspaces" _WINDOWS_HOST_PATH_RE = re.compile(r"^(?:[A-Za-z]:[/\\]|\\\\|//)") +# Drive-letter root (C:/, C:\, D:) — Windows counterpart of POSIX ``/``. +_WINDOWS_DRIVE_ROOT_RE = re.compile(r"^[A-Za-z]:[/\\]?$") def _running_on_windows() -> bool: @@ -43,9 +45,19 @@ def _running_on_windows() -> bool: def _is_host_root_sentinel(root: str | Path | None) -> bool: + """True when *root* means the whole host filesystem (not a jail). + + POSIX uses ``/``. Windows uses a drive root such as ``C:/`` (what + :func:`host_fs_tree_root` returns). Treating drive roots as scoped jails + would put default workspaces under ``{drive}/.octop/workspaces/…`` instead + of ``{OCTOP_HOME}/agents//``. + """ if root is None: return True - return str(root).strip() in ("/", "\\", "") + text = str(root).strip() + if text in ("/", "\\", ""): + return True + return bool(_WINDOWS_DRIVE_ROOT_RE.match(text)) def _is_windows_style_host_path(raw: str) -> bool: diff --git a/src/octop/infra/db/repos/agents.py b/src/octop/infra/db/repos/agents.py index 563d6eb947..bd723860cf 100644 --- a/src/octop/infra/db/repos/agents.py +++ b/src/octop/infra/db/repos/agents.py @@ -168,6 +168,24 @@ def list_by_user(self, user_id: int, *, include_disabled: bool = True) -> list[A rows = conn.execute(sql, (user_id,)).fetchall() return map_rows(rows, AgentRow) + def count_by_user( + self, + user_id: int, + *, + kind: str | None = None, + include_disabled: bool = True, + ) -> int: + sql = "SELECT COUNT(*) AS c FROM agents WHERE user_id = ?" + params: list[object] = [user_id] + if kind is not None: + sql += " AND kind = ?" + params.append(kind) + if not include_disabled: + sql += " AND enabled = 1" + with self._db.connect() as conn: + row = conn.execute(sql, params).fetchone() + return int(row["c"]) if row is not None else 0 + def list_all(self, *, include_disabled: bool = True) -> list[AgentRow]: sql = "SELECT * FROM agents" if not include_disabled: diff --git a/src/octop/infra/errors.py b/src/octop/infra/errors.py index 7a4419b40d..a4128a9f3d 100644 --- a/src/octop/infra/errors.py +++ b/src/octop/infra/errors.py @@ -111,6 +111,7 @@ class ErrorCode(StrEnum): WORKSPACE_ROOT_RESTRICTED = "WORKSPACE_ROOT_RESTRICTED" WORKSPACE_ROOT_CONTAINER_UNSUPPORTED = "WORKSPACE_ROOT_CONTAINER_UNSUPPORTED" TOKEN_QUOTA_EXCEEDED = "TOKEN_QUOTA_EXCEEDED" + AGENT_QUOTA_EXCEEDED = "AGENT_QUOTA_EXCEEDED" CAPTCHA_REQUIRED = "CAPTCHA_REQUIRED" CAPTCHA_FAILED = "CAPTCHA_FAILED" CONFIG_FILE_CORRUPT = "CONFIG_FILE_CORRUPT" @@ -222,6 +223,7 @@ class ErrorCode(StrEnum): ErrorCode.WORKSPACE_ROOT_RESTRICTED: 400, ErrorCode.WORKSPACE_ROOT_CONTAINER_UNSUPPORTED: 400, ErrorCode.TOKEN_QUOTA_EXCEEDED: 403, + ErrorCode.AGENT_QUOTA_EXCEEDED: 403, ErrorCode.CAPTCHA_REQUIRED: 400, ErrorCode.CAPTCHA_FAILED: 400, ErrorCode.CONFIG_FILE_CORRUPT: 400, diff --git a/src/octop/infra/gateway/media/tool_media.py b/src/octop/infra/gateway/media/tool_media.py index 01c3f33114..54d0bb772e 100644 --- a/src/octop/infra/gateway/media/tool_media.py +++ b/src/octop/infra/gateway/media/tool_media.py @@ -44,6 +44,22 @@ logger = logging.getLogger(__name__) _MEDIA_BLOCK_TYPES = frozenset({"image", "video", "audio", "file"}) +# Only these tools may push media to the user (dashboard attachments / IM). +# write_file / read_file / browser dumps often look like media and would re-push +# prior outbound files if every tool_result were treated as a delivery. +MEDIA_PUSH_TOOL_BASES = frozenset( + { + "send_file", + "send_file_to_user", + "desktop_screenshot", + "mobile_screenshot", + "generate_image", + "generate_video", + } +) +# browser_use / write_file / read_file may embed media-shaped payloads or +# "saved to outbound/…" text for tool-card previews; they are intentionally +# excluded so screenshots and writes do not auto-push into the chat bubble. _PLAIN_IMAGE_PATH_RE = re.compile( r"(?:saved to|written to|file(?:\s+path)?[:\s]+)\s*([^\s(]+\.(?:png|jpe?g|gif|webp|bmp|svg))", re.IGNORECASE, @@ -56,6 +72,67 @@ ) +def tool_name_base(name: str) -> str: + trimmed = (name or "").strip() + slash = trimmed.rfind("/") + return trimmed[slash + 1 :] if slash >= 0 else trimmed + + +def is_media_push_tool(name: str | None) -> bool: + """True when this tool is allowed to push files/images to the user.""" + return tool_name_base(name or "").lower() in MEDIA_PUSH_TOOL_BASES + + +def tool_message_name(message: Any, *, chunk_name: str | None = None) -> str | None: + """Resolve tool name from a ToolMessage / dict, falling back to chunk.name.""" + if isinstance(message, dict): + raw = message.get("name") + if isinstance(raw, str) and raw.strip(): + return raw.strip() + else: + raw = getattr(message, "name", None) + if isinstance(raw, str) and raw.strip(): + return raw.strip() + if isinstance(chunk_name, str) and chunk_name.strip(): + return chunk_name.strip() + return None + + +def dedup_tool_result_messages( + chunk: dict[str, Any], + emitted_ids: set[str], +) -> dict[str, Any] | None: + """Keep only ``tool_result`` messages not yet emitted in this stream. + + When ``PatchToolCallsMiddleware`` returns ``Overwrite(full_history)`` the + chunk can contain every prior ToolMessage. Skip ones whose ``tool_call_id`` + was already pushed so old files/images are not re-sent. + + Returns the (possibly trimmed) chunk, or ``None`` when all messages are + duplicates. + """ + messages = chunk.get("messages") + if not isinstance(messages, list): + return chunk + + new_messages: list[Any] = [] + for msg in messages: + msg_id = getattr(msg, "tool_call_id", None) or ( + msg.get("tool_call_id") if isinstance(msg, dict) else None + ) + if msg_id and msg_id in emitted_ids: + continue + new_messages.append(msg) + if msg_id: + emitted_ids.add(msg_id) + + if not new_messages: + return None + if len(new_messages) == len(messages): + return chunk + return {**chunk, "messages": new_messages} + + def _generated_media_blocks(value: Any) -> list[dict[str, Any]]: """Convert WorkBuddy-style generation envelopes into standard media blocks.""" if not isinstance(value, dict): @@ -337,10 +414,15 @@ async def enrich_tool_result_with_backend( if not isinstance(messages, list): return chunk + chunk_name = chunk.get("name") if isinstance(chunk.get("name"), str) else None out_messages: list[Any] = [] for message in messages: content = extract_message_content(message) - if isinstance(content, str): + # Plain-text path inference invents image blocks from phrases like + # "saved to outbound/…". Only delivery tools should get that treatment. + if isinstance(content, str) and is_media_push_tool( + tool_message_name(message, chunk_name=chunk_name) + ): stripped = content.strip() if stripped and stripped[0] not in "{[": plain_enriched = await _enrich_plain_text_tool_media( @@ -565,18 +647,78 @@ def _attachment_kind(block_type: str) -> str: return "file" +def attachment_frame_dedup_key(frame: dict[str, Any]) -> str | None: + """Stable key for skipping duplicate attachment frames in one stream.""" + path = frame.get("path") + if isinstance(path, str) and path.strip(): + return f"path:{path.strip()}" + preview = frame.get("preview_url") + if isinstance(preview, str) and preview.strip(): + return f"url:{preview.strip()}" + url = frame.get("url") + if isinstance(url, str) and url.strip(): + return f"url:{url.strip()}" + data = frame.get("data") + if isinstance(data, str) and data: + return f"data:{hash(data)}" + return None + + +async def iter_dashboard_attachment_frames( + chunk: dict[str, Any], + *, + agent_id: str, + workspace: BackendWorkspace, + saw_tool_call: bool, + emitted_media_ids: set[str], + emitted_attachment_keys: set[str], +) -> AsyncIterator[dict[str, Any]]: + """Yield pushable dashboard attachments for this turn. + + Gates (in order): + 1. At least one ``tool_call_chunk`` this stream (skip historical Overwrite + dumps at turn start — same as IM ``saw_tool_call``). + 2. ``tool_call_id`` not already emitted (Overwrite replay dedup). + 3. Allowlisted delivery tools inside :func:`attachment_frames_from_tool_result`. + 4. Path / URL / payload key not already pushed (same file, new call id). + """ + if not saw_tool_call: + return + chunk_for_media = dedup_tool_result_messages(chunk, emitted_media_ids) + if chunk_for_media is None: + return + async for frame in attachment_frames_from_tool_result( + chunk_for_media, + agent_id=agent_id, + workspace=workspace, + ): + key = attachment_frame_dedup_key(frame) + if key is not None: + if key in emitted_attachment_keys: + continue + emitted_attachment_keys.add(key) + yield frame + + async def attachment_frames_from_tool_result( chunk: dict[str, Any], *, agent_id: str, workspace: BackendWorkspace, ) -> AsyncIterator[dict[str, Any]]: - """Yield dashboard WS attachment frames with workspace download URLs.""" + """Yield dashboard WS attachment frames with workspace download URLs. + + Only :data:`MEDIA_PUSH_TOOL_BASES` results produce frames — other tools may + embed media-shaped payloads without intending a user-facing push. + """ messages = chunk.get("messages") if not isinstance(messages, list): return + chunk_name = chunk.get("name") if isinstance(chunk.get("name"), str) else None for message in messages: + if not is_media_push_tool(tool_message_name(message, chunk_name=chunk_name)): + continue for block in iter_media_blocks(extract_message_content(message)): preview_url = block.get("preview_url") if not isinstance(preview_url, str) or not preview_url: @@ -760,11 +902,15 @@ async def media_events_from_tool_result( *, workspace: BackendWorkspace, ) -> AsyncIterator[MessageEvent]: + """Yield IM media events from allowlisted delivery-tool results only.""" messages = chunk.get("messages") or [] if not isinstance(messages, list): return + chunk_name = chunk.get("name") if isinstance(chunk.get("name"), str) else None for message in messages: + if not is_media_push_tool(tool_message_name(message, chunk_name=chunk_name)): + continue for block in iter_media_blocks(extract_message_content(message)): try: part = await block_to_content_part(block, workspace=workspace) @@ -780,12 +926,19 @@ async def media_events_from_tool_result( __all__ = [ + "MEDIA_PUSH_TOOL_BASES", + "attachment_frame_dedup_key", "attachment_frames_from_tool_result", "block_to_content_part", + "dedup_tool_result_messages", "enrich_tool_output_string", "enrich_tool_result_for_dashboard", "enrich_tool_result_with_backend", + "is_media_push_tool", + "iter_dashboard_attachment_frames", "iter_media_blocks", "media_events_from_tool_result", "read_media_block_bytes", + "tool_message_name", + "tool_name_base", ] diff --git a/src/octop/infra/gateway/process/processor.py b/src/octop/infra/gateway/process/processor.py index 69dea6a7c0..480421fc04 100644 --- a/src/octop/infra/gateway/process/processor.py +++ b/src/octop/infra/gateway/process/processor.py @@ -39,9 +39,9 @@ ) from octop.infra.gateway.media.attachment_hints import content_blocks_need_vision from octop.infra.gateway.media.tool_media import ( - attachment_frames_from_tool_result, enrich_tool_result_for_dashboard, enrich_tool_result_with_backend, + iter_dashboard_attachment_frames, ) from octop.infra.gateway.process.agent_resolve import ( harness_workspace_for_agent, @@ -1013,6 +1013,11 @@ async def iter_turn_chunks(self, msg: InboundMessage) -> AsyncIterator[dict[str, persist_failed_turn = False harness_workspace = harness_workspace_for_agent(self._agent_manager, agent_id) usage_tracker = UsageTracker() + # Align with IM stream_project: only push after a live tool_call this + # turn, then dedupe Overwrite replays and identical path/URL frames. + saw_tool_call = False + emitted_media_ids: set[str] = set() + emitted_attachment_keys: set[str] = set() try: async for chunk in self._agent_manager.stream(agent_id, request): @@ -1042,6 +1047,8 @@ async def iter_turn_chunks(self, msg: InboundMessage) -> AsyncIterator[dict[str, channel_type=channel_type, ), ) + if chunk.get("type") == "tool_call_chunk": + saw_tool_call = True if chunk.get("type") == "tool_result": if harness_workspace is not None: chunk = await enrich_tool_result_with_backend( @@ -1049,10 +1056,13 @@ async def iter_turn_chunks(self, msg: InboundMessage) -> AsyncIterator[dict[str, agent_id=agent_id, workspace=harness_workspace, ) - async for att in attachment_frames_from_tool_result( + async for att in iter_dashboard_attachment_frames( chunk, agent_id=agent_id, workspace=harness_workspace, + saw_tool_call=saw_tool_call, + emitted_media_ids=emitted_media_ids, + emitted_attachment_keys=emitted_attachment_keys, ): yield _maybe_stamp_team_host(att, agent_id, team_host) else: diff --git a/src/octop/infra/gateway/process/stream_project.py b/src/octop/infra/gateway/process/stream_project.py index a49a665056..fe90225bdf 100644 --- a/src/octop/infra/gateway/process/stream_project.py +++ b/src/octop/infra/gateway/process/stream_project.py @@ -11,7 +11,10 @@ from octop.i18n import channel_tool_hint_end, channel_tool_hint_start, tool_display_name from octop.infra.gateway.hitl.format import format_hitl_card -from octop.infra.gateway.media.tool_media import media_events_from_tool_result +from octop.infra.gateway.media.tool_media import ( + dedup_tool_result_messages, + media_events_from_tool_result, +) from octop.infra.gateway.process.agent_resolve import harness_workspace_for_agent from octop.infra.gateway.process.history_projection import TurnHistoryTracker from octop.infra.gateway.process.usage_record import UsageTracker @@ -44,42 +47,6 @@ class _ToolProjectionState: emitted_media_ids: set[str] = field(default_factory=set) -def _dedup_tool_result_messages( - chunk: dict[str, Any], - emitted_ids: set[str], -) -> dict[str, Any] | None: - """Filter ``tool_result`` messages to those not yet emitted this stream. - - When ``PatchToolCallsMiddleware`` returns ``Overwrite(full_history)`` the - ``tool_result`` chunk contains every ToolMessage ever (including ones from - previous turns). We keep only messages whose ``tool_call_id`` hasn't been - seen yet in this stream, then record the new ones. - - Returns the (possibly trimmed) chunk, or ``None`` when all messages are - duplicates. - """ - messages = chunk.get("messages") - if not isinstance(messages, list): - return chunk - - new_messages = [] - for msg in messages: - msg_id = getattr(msg, "tool_call_id", None) or ( - msg.get("tool_call_id") if isinstance(msg, dict) else None - ) - if msg_id and msg_id in emitted_ids: - continue - new_messages.append(msg) - if msg_id: - emitted_ids.add(msg_id) - - if not new_messages: - return None - if len(new_messages) == len(messages): - return chunk - return {**chunk, "messages": new_messages} - - def enrich_tool_stream_chunk( chunk: dict[str, Any], locale: str | Locale, @@ -199,7 +166,7 @@ def _tool_end(raw: str) -> MessageEvent: tool_state.tool_started.discard(idx) tool_state.active_tool_idx = None if harness_workspace is not None and tool_state.saw_tool_call: - chunk_for_media = _dedup_tool_result_messages(chunk, tool_state.emitted_media_ids) + chunk_for_media = dedup_tool_result_messages(chunk, tool_state.emitted_media_ids) if chunk_for_media is not None: async for media_event in media_events_from_tool_result( chunk_for_media, diff --git a/src/octop/infra/users/manager.py b/src/octop/infra/users/manager.py index e95a426cf0..4e81333b64 100644 --- a/src/octop/infra/users/manager.py +++ b/src/octop/infra/users/manager.py @@ -551,10 +551,13 @@ async def set_resource_policy( *, workspace_root_dir: Any = UNSET, token_quota: Any = UNSET, + max_agents: Any = UNSET, ) -> None: from octop.infra.users.resource_policy import ( + POLICY_MAX_AGENTS, POLICY_TOKEN_QUOTA, POLICY_WORKSPACE_ROOT_DIR, + normalize_max_agents, normalize_token_quota, normalize_workspace_root_dir, ) @@ -565,12 +568,16 @@ async def set_resource_policy( updates: dict[str, str | None] = {} root_arg: Any = UNSET quota_arg: Any = UNSET + max_agents_arg: Any = UNSET if workspace_root_dir is not UNSET: root_arg = normalize_workspace_root_dir(workspace_root_dir) updates[POLICY_WORKSPACE_ROOT_DIR] = root_arg if token_quota is not UNSET: quota_arg = normalize_token_quota(token_quota) updates[POLICY_TOKEN_QUOTA] = None if quota_arg is None else str(quota_arg) + if max_agents is not UNSET: + max_agents_arg = normalize_max_agents(max_agents) + updates[POLICY_MAX_AGENTS] = None if max_agents_arg is None else str(max_agents_arg) if not updates: return self._services.user_policy_repo.merge(row.id, updates) @@ -588,6 +595,13 @@ async def set_resource_policy( target=username, payload=str(quota_arg) if quota_arg is not None else "", ) + if max_agents is not UNSET: + self._services.audit_repo.write( + actor=ACTOR_ADMIN, + action="user.set_max_agents", + target=username, + payload=str(max_agents_arg) if max_agents_arg is not None else "", + ) async def set_workspace_root_dir(self, username: str, workspace_root_dir: str | None) -> None: await self.set_resource_policy(username, workspace_root_dir=workspace_root_dir) @@ -595,6 +609,9 @@ async def set_workspace_root_dir(self, username: str, workspace_root_dir: str | async def set_token_quota(self, username: str, token_quota: int | None) -> None: await self.set_resource_policy(username, token_quota=token_quota) + async def set_max_agents(self, username: str, max_agents: int | None) -> None: + await self.set_resource_policy(username, max_agents=max_agents) + async def set_role(self, username: str, role: Role) -> None: row = self._services.user_repo.get_by_username(username) if row is None: diff --git a/src/octop/infra/users/resource_policy.py b/src/octop/infra/users/resource_policy.py index 216996fecd..09a1e6a135 100644 --- a/src/octop/infra/users/resource_policy.py +++ b/src/octop/infra/users/resource_policy.py @@ -16,6 +16,7 @@ POLICY_WORKSPACE_ROOT_DIR = "workspace_root_dir" POLICY_TOKEN_QUOTA = "token_quota" +POLICY_MAX_AGENTS = "max_agents" def active_policy_value(row: Any) -> str | None: @@ -73,6 +74,20 @@ def token_quota_of(raw: Any) -> int | None: return None +def max_agents_of(raw: Any) -> int | None: + if isinstance(raw, int): + return raw + if isinstance(raw, Mapping) and "value" not in raw: + raw = raw.get(POLICY_MAX_AGENTS) + text = active_policy_value(raw) + if text is None: + return None + try: + return int(text) + except (TypeError, ValueError): + return None + + def public_policy_fields(rows: Sequence[Any] | Mapping[str, str] | None) -> dict[str, Any]: """HTTP-facing subset of known policies (disabled / missing = unlimited).""" by_name: dict[str, str] = {} @@ -84,10 +99,10 @@ def public_policy_fields(rows: Sequence[Any] | Mapping[str, str] | None) -> dict value = active_policy_value(row) if isinstance(name, str) and value is not None: by_name[name] = value - quota = token_quota_of(by_name.get(POLICY_TOKEN_QUOTA)) return { "workspace_root_dir": workspace_root_dir_of(by_name), - "token_quota": quota, + "token_quota": token_quota_of(by_name.get(POLICY_TOKEN_QUOTA)), + "max_agents": max_agents_of(by_name.get(POLICY_MAX_AGENTS)), } @@ -122,6 +137,15 @@ def normalize_token_quota(raw: int | None) -> int | None: return quota +def normalize_max_agents(raw: int | None) -> int | None: + if raw is None: + return None + limit = int(raw) + if limit < 0: + raise OctopError(ErrorCode.FORBIDDEN, "max agents must be >= 0", status=400) + return limit + + def assert_backend_within_user_root(backend: Any, allowed_root: str | None) -> None: """Raise ``ValueError`` when a local backend root is outside *allowed_root*.""" if backend is None: @@ -156,3 +180,17 @@ def assert_token_quota_available(policy_repo: Any, usage_repo: Any, user_id: int "token quota exceeded", details={"used": used, "quota": quota}, ) + + +def assert_agent_quota_available(policy_repo: Any, agent_repo: Any, user_id: int) -> None: + """Raise when the user already owns ``max_agents`` expert agents.""" + limit = max_agents_of(policy_repo.get(user_id, POLICY_MAX_AGENTS)) + if limit is None: + return + owned = int(agent_repo.count_by_user(user_id, kind="expert")) + if owned >= limit: + raise OctopError( + ErrorCode.AGENT_QUOTA_EXCEEDED, + "agent quota exceeded", + details={"used": owned, "quota": limit}, + ) diff --git a/src/octop/infra/utils/host_dirs.py b/src/octop/infra/utils/host_dirs.py index 7308b219a9..4ac5dfa8af 100644 --- a/src/octop/infra/utils/host_dirs.py +++ b/src/octop/infra/utils/host_dirs.py @@ -37,26 +37,23 @@ def host_home_dir() -> Path: return Path(os.path.realpath(os.path.expanduser(str(Path.home())))) -def host_fs_tree_root(*, allow_outside_home: bool) -> str: - """Browse-tree root for the root_dir picker. +def host_fs_tree_root() -> str: + """Browse-tree / UI default root for unrestricted users. - When *allow_outside_home* is true (current API default for all users): - host ``/`` on POSIX, or the home drive root (e.g. ``C:/``) on Windows. - Otherwise: the process home directory (legacy helper mode). + Host ``/`` on POSIX, or the home drive root (e.g. ``C:/``) on Windows. + Policy-restricted callers should use the jail path instead. """ - home = host_home_dir() - if not allow_outside_home: - return host_path_text(home) if os.name == "posix": return "/" - return host_path_text(Path(home.anchor)) + return host_path_text(Path(host_home_dir().anchor)) def running_in_container() -> bool: """True when the Octop process appears to run inside a container. - Used so Docker (and Podman) deployments default local backend ``root_dir`` - to filesystem root — the container is already the isolation boundary. + Used so Docker (and Podman) deployments ignore host ``workspace_root_dir`` + policy (the container is already the isolation boundary) and so the UI can + surface container-specific copy. Override with ``OCTOP_IN_CONTAINER=1|0`` (also ``true``/``false``). """ @@ -70,18 +67,6 @@ def running_in_container() -> bool: return Path("/.dockerenv").is_file() or Path("/run/.containerenv").is_file() -def default_host_root_dir(*, allow_outside_home: bool) -> str: - """UI default for local backend ``root_dir``. - - Process home on bare metal; filesystem root when running in a container - (and browsing outside home is allowed). Policy-restricted callers should - pass their jail path instead of calling this helper. - """ - if allow_outside_home and running_in_container(): - return host_fs_tree_root(allow_outside_home=True) - return host_path_text(host_home_dir()) - - def _path_within_base(resolved: str, base: str) -> bool: """True when *resolved* equals *base* or is a subdirectory (normcase-safe). @@ -120,8 +105,8 @@ def _is_denied_host_path(resolved: Path) -> bool: if os.name != "posix": return False # Process home may coincide with a denylist prefix (uid 0 → ``/root``). - # The UI defaults ``root_dir`` to home, so home and its subdirs must stay - # selectable; ``/root`` remains denied for non-root process homes. + # The UI defaults ``root_dir`` to filesystem root, so home and its subdirs + # must stay selectable; ``/root`` remains denied for non-root process homes. if is_within_host_home(resolved): return False text = resolved.as_posix() diff --git a/tests/integration/test_filesystem_api.py b/tests/integration/test_filesystem_api.py index 7d1cc75272..bb14de7854 100644 --- a/tests/integration/test_filesystem_api.py +++ b/tests/integration/test_filesystem_api.py @@ -111,11 +111,11 @@ async def test_filesystem_defaults_for_admin( r = await client.get("/api/filesystem/defaults", headers=auth) assert r.status_code == 200, r.text body = r.json() - assert body["home"] == home.resolve().as_posix() - assert body["default_root_dir"] == home.resolve().as_posix() - assert body["allow_outside_home"] is True - assert body["tree_root"] == host_fs_tree_root(allow_outside_home=True) + assert body["default_root_dir"] == host_fs_tree_root() + assert body["tree_root"] == host_fs_tree_root() assert body["in_container"] is False + assert "home" not in body + assert "allow_outside_home" not in body @pytest.mark.asyncio @@ -135,11 +135,11 @@ async def test_filesystem_defaults_in_container( r = await client.get("/api/filesystem/defaults", headers=auth) assert r.status_code == 200, r.text body = r.json() - assert body["home"] == home.resolve().as_posix() - assert body["default_root_dir"] == host_fs_tree_root(allow_outside_home=True) - assert body["allow_outside_home"] is True - assert body["tree_root"] == host_fs_tree_root(allow_outside_home=True) + assert body["default_root_dir"] == host_fs_tree_root() + assert body["tree_root"] == host_fs_tree_root() assert body["in_container"] is True + assert "home" not in body + assert "allow_outside_home" not in body @pytest.mark.asyncio @@ -164,10 +164,11 @@ async def test_non_admin_can_list_outside_home( defaults = await client.get("/api/filesystem/defaults", headers=user_auth) assert defaults.status_code == 200, defaults.text body = defaults.json() - assert body["allow_outside_home"] is True - assert body["default_root_dir"] == home.resolve().as_posix() - assert body["tree_root"] == host_fs_tree_root(allow_outside_home=True) + assert body["default_root_dir"] == host_fs_tree_root() + assert body["tree_root"] == host_fs_tree_root() assert body["in_container"] is False + assert "home" not in body + assert "allow_outside_home" not in body listed = await client.get( f"/api/filesystem/dirs?path={outside.as_posix()}", @@ -338,7 +339,8 @@ async def test_filesystem_respects_user_workspace_root( body = defaults.json() assert body["tree_root"] == jail.resolve().as_posix() assert body["default_root_dir"] == jail.resolve().as_posix() - assert body["allow_outside_home"] is False + assert "home" not in body + assert "allow_outside_home" not in body inside = await client.get( f"/api/filesystem/dirs?path={nested.as_posix()}", diff --git a/tests/integration/test_invites_api.py b/tests/integration/test_invites_api.py index 0a4d1adbf2..ab368b51f8 100644 --- a/tests/integration/test_invites_api.py +++ b/tests/integration/test_invites_api.py @@ -5,7 +5,7 @@ import json from octop.infra.agents.default_agent import default_home_local_backend -from octop.infra.utils.host_dirs import host_home_dir, host_path_text +from octop.infra.utils.host_dirs import host_fs_tree_root async def test_invite_create_list_redeem_and_one_time(env) -> None: @@ -72,8 +72,10 @@ async def test_invite_create_list_redeem_and_one_time(env) -> None: assert bob_row is not None bob_cfg = json.loads(bob_row.config_json or "{}") assert bob_cfg["backend"] == default_home_local_backend() - assert bob_cfg["backend"]["root_dir"] == host_path_text(host_home_dir()) - assert bob_cfg["workspace_dir"] == f"/.octop/workspaces/{bob_agents[0]['agent_id']}" + assert bob_cfg["backend"]["root_dir"] == host_fs_tree_root() + assert bob_cfg["workspace_dir"] == str( + srv.paths.agent_workspace(bob_agents[0]["agent_id"]).resolve() + ) again = await c.post( "/api/auth/invite/redeem", diff --git a/tests/integration/test_setup_bootstrap.py b/tests/integration/test_setup_bootstrap.py index 3a123007a1..d56a4be000 100644 --- a/tests/integration/test_setup_bootstrap.py +++ b/tests/integration/test_setup_bootstrap.py @@ -102,14 +102,16 @@ async def test_main_agent_seeds_general_assistant_workspace(patched_app_client: """Finish creates main with expert files even when no provider is configured yet.""" c, _srv, home = patched_app_client await bootstrap_admin(c, home) - ws = home / "workspaces" / "main" + ws = home / "agents" / "main" assert (ws / "SOUL.md").is_file() # New agents use system_files_path=.octop — BackendWorkspace remaps skills/. assert (ws / ".octop" / "skills" / "octop-assistant" / "SKILL.md").is_file() -async def test_main_agent_uses_home_scoped_backend(patched_app_client: Any) -> None: - """Default main uses the same home-scoped local backend as dashboard-created agents.""" +async def test_main_agent_uses_fs_root_backend(patched_app_client: Any) -> None: + """Default main uses the same FS-root local backend as dashboard-created agents.""" + from octop.infra.utils.host_dirs import host_fs_tree_root + c, srv, home = patched_app_client await bootstrap_admin(c, home) assert srv.app_runtime is not None @@ -118,10 +120,10 @@ async def test_main_agent_uses_home_scoped_backend(patched_app_client: Any) -> N cfg = json.loads(row.config_json or "{}") assert cfg["backend"] == { "type": "local_shell", - "root_dir": home.parent.resolve().as_posix(), + "root_dir": host_fs_tree_root(), "virtual_mode": True, } - assert cfg["workspace_dir"] == "/.octop/workspaces/main" + assert cfg["workspace_dir"] == str((home / "agents" / "main").resolve()) async def test_main_agent_uses_general_assistant_template(patched_app_client: Any) -> None: diff --git a/tests/integration/test_users_api.py b/tests/integration/test_users_api.py index 62309f4600..6adcf2bda6 100644 --- a/tests/integration/test_users_api.py +++ b/tests/integration/test_users_api.py @@ -245,3 +245,163 @@ async def test_admin_can_set_resource_policy(env, tmp_path, monkeypatch): assert r.status_code == 200 assert r.json()["workspace_root_dir"] is None assert r.json()["token_quota"] is None + + +async def test_batch_enable_disable_delete_and_token_quota(env): + c, _srv, auth = env + me = (await c.get("/api/auth/me", headers=auth)).json() + + created_ids: list[int] = [] + for name in ("batch_a", "batch_b", "batch_c"): + r = await c.post( + "/api/users", + headers=auth, + json={"username": name, "password": "TestPass12", "role": "user"}, + ) + assert r.status_code == 201, r.text + created_ids.append(r.json()["id"]) + + r = await c.post( + "/api/users/batch", + headers=auth, + json={"user_ids": created_ids, "action": "disable"}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["action"] == "disable" + assert body["succeeded"] == 3 + assert body["failed"] == 0 + listed = {u["username"]: u for u in (await c.get("/api/users", headers=auth)).json()} + assert listed["batch_a"]["disabled"] is True + assert listed["batch_b"]["disabled"] is True + + r = await c.post( + "/api/users/batch", + headers=auth, + json={"user_ids": created_ids[:2], "action": "enable"}, + ) + assert r.status_code == 200 + assert r.json()["succeeded"] == 2 + listed = {u["username"]: u for u in (await c.get("/api/users", headers=auth)).json()} + assert listed["batch_a"]["disabled"] is False + assert listed["batch_c"]["disabled"] is True + + r = await c.post( + "/api/users/batch", + headers=auth, + json={ + "user_ids": created_ids, + "action": "set_token_quota", + "token_quota": 10_000_000, + }, + ) + assert r.status_code == 200 + assert r.json()["succeeded"] == 3 + listed = {u["id"]: u for u in (await c.get("/api/users", headers=auth)).json()} + for uid in created_ids: + assert listed[uid]["token_quota"] == 10_000_000 + + r = await c.post( + "/api/users/batch", + headers=auth, + json={ + "user_ids": [created_ids[0]], + "action": "set_token_quota", + "token_quota": None, + }, + ) + assert r.status_code == 200 + listed = {u["id"]: u for u in (await c.get("/api/users", headers=auth)).json()} + assert listed[created_ids[0]]["token_quota"] is None + + # Self is skipped for delete; others succeed. + r = await c.post( + "/api/users/batch", + headers=auth, + json={"user_ids": [me["id"], *created_ids], "action": "delete"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["succeeded"] == 3 + assert body["failed"] == 1 + self_result = next(item for item in body["results"] if item["user_id"] == me["id"]) + assert self_result["ok"] is False + assert self_result["code"] == "FORBIDDEN" + listed = (await c.get("/api/users", headers=auth)).json() + usernames = {u["username"] for u in listed} + assert "batch_a" not in usernames + assert me["username"] in usernames + + +async def test_batch_disable_skips_self(env): + c, _srv, auth = env + me = (await c.get("/api/auth/me", headers=auth)).json() + r = await c.post( + "/api/users", + headers=auth, + json={"username": "batch_other", "password": "TestPass12", "role": "user"}, + ) + other_id = r.json()["id"] + + r = await c.post( + "/api/users/batch", + headers=auth, + json={"user_ids": [me["id"], other_id], "action": "disable"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["succeeded"] == 1 + assert body["failed"] == 1 + listed = {u["id"]: u for u in (await c.get("/api/users", headers=auth)).json()} + assert listed[me["id"]]["disabled"] is False + assert listed[other_id]["disabled"] is True + + +async def test_admin_can_set_max_agents_and_block_create(env): + from tests.support.auth import TEST_PASSWORD, create_user + + c, _srv, auth = env + user_auth = await create_user(c, auth, username="quota_user", password=TEST_PASSWORD) + listed = (await c.get("/api/users", headers=auth)).json() + uid = next(u["id"] for u in listed if u["username"] == "quota_user") + + r = await c.patch( + f"/api/users/{uid}", + headers=auth, + json={"max_agents": 1}, + ) + assert r.status_code == 200, r.text + assert r.json()["max_agents"] == 1 + + first = await c.post( + "/api/agents", + headers=user_auth, + json={"name": "expert-one"}, + ) + assert first.status_code == 201, first.text + + second = await c.post( + "/api/agents", + headers=user_auth, + json={"name": "expert-two"}, + ) + assert second.status_code == 403, second.text + assert second.json()["error"]["code"] == "AGENT_QUOTA_EXCEEDED" + assert second.json()["error"]["details"] == {"used": 1, "quota": 1} + + r = await c.post( + "/api/users/batch", + headers=auth, + json={"user_ids": [uid], "action": "set_max_agents", "max_agents": None}, + ) + assert r.status_code == 200 + assert r.json()["succeeded"] == 1 + listed = {u["id"]: u for u in (await c.get("/api/users", headers=auth)).json()} + assert listed[uid]["max_agents"] is None + + third = await c.post( + "/api/agents", + headers=user_auth, + json={"name": "expert-three"}, + ) + assert third.status_code == 201, third.text diff --git a/tests/unit/agents/test_default_agent.py b/tests/unit/agents/test_default_agent.py index 5e6395fd40..c9edce715d 100644 --- a/tests/unit/agents/test_default_agent.py +++ b/tests/unit/agents/test_default_agent.py @@ -15,6 +15,7 @@ ) from octop.infra.agents.experts.catalog import ExpertCatalog, default_library_root from octop.infra.errors import OctopError +from octop.infra.utils.host_dirs import host_fs_tree_root @pytest.fixture(scope="module") @@ -50,10 +51,7 @@ async def test_bootstrap_skips_when_main_exists(catalog: ExpertCatalog) -> None: registry.create.assert_not_called() -async def test_bootstrap_creates_general_assistant( - catalog: ExpertCatalog, - _isolated_user_home: Path, -) -> None: +async def test_bootstrap_creates_general_assistant(catalog: ExpertCatalog) -> None: registry = MagicMock() registry.get_row.return_value = None registry.list_agents.return_value = [] @@ -67,14 +65,11 @@ async def test_bootstrap_creates_general_assistant( assert spec.agent_id is None assert spec.template_name == DEFAULT_EXPERT_ID assert spec.config["backend"] == default_home_local_backend() - assert spec.config["backend"]["root_dir"] == _isolated_user_home.resolve().as_posix() + assert spec.config["backend"]["root_dir"] == host_fs_tree_root() assert registry.create.await_args.kwargs["defer_bootstrap"] is True -async def test_bootstrap_main_uses_home_backend( - catalog: ExpertCatalog, - _isolated_user_home: Path, -) -> None: +async def test_bootstrap_main_uses_fs_root_backend(catalog: ExpertCatalog) -> None: registry = MagicMock() registry.get_row.return_value = None registry.list_agents.return_value = [] @@ -89,7 +84,27 @@ async def test_bootstrap_main_uses_home_backend( spec = registry.create.await_args.args[0] assert spec.config["backend"] == default_home_local_backend() - assert spec.config["backend"]["root_dir"] == _isolated_user_home.resolve().as_posix() + assert spec.config["backend"]["root_dir"] == host_fs_tree_root() + + +async def test_bootstrap_respects_policy_root_dir(catalog: ExpertCatalog, tmp_path: Path) -> None: + registry = MagicMock() + registry.get_row.return_value = None + registry.list_agents.return_value = [] + registry.create = AsyncMock(return_value=object()) + jail = (tmp_path / "jail").resolve().as_posix() + + await bootstrap_default_agent( + registry, + catalog, + user_id=1, + agent_id=None, + root_dir=jail, + ) + + spec = registry.create.await_args.args[0] + assert spec.config["backend"] == default_home_local_backend(root_dir=jail) + assert spec.config["backend"]["root_dir"] == jail async def test_bootstrap_requires_catalog() -> None: diff --git a/tests/unit/agents/test_thread_artifacts.py b/tests/unit/agents/test_thread_artifacts.py index 0bf613d62d..7a14842487 100644 --- a/tests/unit/agents/test_thread_artifacts.py +++ b/tests/unit/agents/test_thread_artifacts.py @@ -45,6 +45,7 @@ def test_is_artifact_tool_name() -> None: assert is_artifact_tool_name("write_file") assert is_artifact_tool_name("ns/edit_file") assert is_artifact_tool_name("desktop_screenshot") + assert is_artifact_tool_name("mobile_screenshot") assert is_artifact_tool_name("send_file_to_user") assert not is_artifact_tool_name("browser_screenshot") assert not is_artifact_tool_name("read_file") diff --git a/tests/unit/agents/test_workspace_dir.py b/tests/unit/agents/test_workspace_dir.py index 6ffc74816c..b1a6798be8 100644 --- a/tests/unit/agents/test_workspace_dir.py +++ b/tests/unit/agents/test_workspace_dir.py @@ -4,6 +4,8 @@ from pathlib import Path +import pytest + from octop.infra.agents.workspace_dir import ( agent_facing_workspace_dir_from_config, agent_facing_workspace_root, @@ -85,6 +87,23 @@ def test_host_rooted_default_uses_octop_home(tmp_path: Path) -> None: assert host == (tmp_path / ".octop" / "agents" / "A1").resolve() +@pytest.mark.parametrize("root_dir", ["C:/", "C:\\", "D:", "d:/"]) +def test_windows_drive_root_is_host_sentinel(tmp_path: Path, root_dir: str) -> None: + """Drive roots must use OCTOP_HOME agents/, not {drive}/.octop/workspaces/.""" + paths = PathLayout(tmp_path / ".octop") + cfg = { + "backend": { + "type": "local_shell", + "root_dir": root_dir, + "virtual_mode": True, + }, + } + host = seed_workspace_dir_on_create(cfg, paths=paths, agent_id="WIN1") + assert cfg["workspace_dir"] == str(host) + assert host == (tmp_path / ".octop" / "agents" / "WIN1").resolve() + assert ".octop/workspaces" not in cfg["workspace_dir"].replace("\\", "/") + + def test_harness_workspace_keeps_absolute_persisted_value(tmp_path: Path) -> None: root = tmp_path / "home" cfg = _scoped_cfg(root, workspace_dir=str(tmp_path / "ws")) diff --git a/tests/unit/gateway/test_tool_media.py b/tests/unit/gateway/test_tool_media.py index 546b90371e..63961e6859 100644 --- a/tests/unit/gateway/test_tool_media.py +++ b/tests/unit/gateway/test_tool_media.py @@ -126,6 +126,7 @@ async def test_attachment_frame_virtual_mode_uses_download_url() -> None: "type": "tool_result", "messages": [ { + "name": "desktop_screenshot", "content": json.dumps( { "type": "image", @@ -177,6 +178,7 @@ async def test_attachment_frame_uses_workspace_download_url() -> None: "type": "tool_result", "messages": [ { + "name": "desktop_screenshot", "content": json.dumps( { "type": "image", @@ -235,6 +237,7 @@ async def test_enrich_send_file_dict_content() -> None: "type": "tool_result", "messages": [ { + "name": "send_file_to_user", "content": { "type": "image", "source": { @@ -319,6 +322,7 @@ async def test_enrich_send_file_keeps_absolute_path_without_copy() -> None: "type": "tool_result", "messages": [ { + "name": "send_file_to_user", "content": { "type": "file", "source": { @@ -363,6 +367,7 @@ async def test_enrich_send_file_rewrites_path_to_dashboard_api() -> None: "type": "tool_result", "messages": [ { + "name": "send_file_to_user", "content": { "type": "file", "source": { @@ -469,3 +474,225 @@ def test_plain_text_image_scan_handles_long_web_output() -> None: started = time.perf_counter() assert enrich_tool_output_string_sync(text, agent_id="A1") == text assert time.perf_counter() - started < 1.0 + + +def test_is_media_push_tool_allowlist() -> None: + from octop.infra.gateway.media.tool_media import is_media_push_tool + + assert is_media_push_tool("send_file_to_user") + assert is_media_push_tool("namespace/desktop_screenshot") + assert is_media_push_tool("mobile_screenshot") + assert is_media_push_tool("generate_image") + assert not is_media_push_tool("write_file") + assert not is_media_push_tool("read_file") + assert not is_media_push_tool("browser_use") + assert not is_media_push_tool(None) + + +@pytest.mark.asyncio +async def test_attachment_frames_skip_non_delivery_tools() -> None: + """write_file / read_file media-shaped results must not push to the user.""" + with tempfile.TemporaryDirectory() as ws: + workspace = _workspace(ws) + await workspace.aupload_bytes(f"{OUTBOUND_DIR}/old.png", b"PNG") + media = { + "type": "image", + "source": { + "type": "url", + "url": f"file://{ws}/{OUTBOUND_DIR}/old.png", + "media_type": "image/png", + }, + "filename": "old.png", + } + for tool_name in ("write_file", "read_file", "web_fetch"): + chunk = { + "type": "tool_result", + "messages": [{"name": tool_name, "content": media}], + } + frames = [ + f + async for f in attachment_frames_from_tool_result( + chunk, + agent_id="agent-1", + workspace=workspace, + ) + ] + assert frames == [], tool_name + + +@pytest.mark.asyncio +async def test_plain_text_media_invent_only_for_delivery_tools() -> None: + with tempfile.TemporaryDirectory() as ws: + shots = Path(ws) / "outbound" / "screenshots" + shots.mkdir(parents=True) + png = shots / "shot.png" + png.write_bytes(b"\x89PNG\r\n") + workspace = _workspace(ws) + text = f"Screenshot saved to {png}" + + write_chunk = { + "type": "tool_result", + "messages": [{"name": "write_file", "content": text}], + } + write_enriched = await enrich_tool_result_with_backend( + write_chunk, + agent_id="A1", + workspace=workspace, + ) + assert write_enriched["messages"][0]["content"] == text + + shot_chunk = { + "type": "tool_result", + "messages": [{"name": "desktop_screenshot", "content": text}], + } + shot_enriched = await enrich_tool_result_with_backend( + shot_chunk, + agent_id="A1", + workspace=workspace, + ) + content = shot_enriched["messages"][0]["content"] + assert content != text + frames = [ + f + async for f in attachment_frames_from_tool_result( + shot_enriched, + agent_id="A1", + workspace=workspace, + ) + ] + assert len(frames) == 1 + assert frames[0]["kind"] == "image" + + +def test_dedup_tool_result_messages_skips_replayed_ids() -> None: + from octop.infra.gateway.media.tool_media import dedup_tool_result_messages + + emitted: set[str] = set() + first = { + "type": "tool_result", + "messages": [ + {"tool_call_id": "c1", "name": "send_file_to_user", "content": "a"}, + {"tool_call_id": "c2", "name": "write_file", "content": "b"}, + ], + } + kept = dedup_tool_result_messages(first, emitted) + assert kept is not None + assert len(kept["messages"]) == 2 + assert emitted == {"c1", "c2"} + + replay = { + "type": "tool_result", + "messages": [ + {"tool_call_id": "c1", "name": "send_file_to_user", "content": "a"}, + {"tool_call_id": "c3", "name": "send_file_to_user", "content": "c"}, + ], + } + kept2 = dedup_tool_result_messages(replay, emitted) + assert kept2 is not None + assert [m["tool_call_id"] for m in kept2["messages"]] == ["c3"] + assert emitted == {"c1", "c2", "c3"} + + assert dedup_tool_result_messages(first, emitted) is None + + +@pytest.mark.asyncio +async def test_iter_dashboard_attachment_frames_requires_live_tool_call() -> None: + """Historical Overwrite dumps before any tool_call_chunk must not push.""" + from octop.infra.gateway.media.tool_media import iter_dashboard_attachment_frames + + with tempfile.TemporaryDirectory() as ws: + workspace = _workspace(ws) + await workspace.aupload_bytes(f"{OUTBOUND_DIR}/prior.png", b"PNG") + chunk = { + "type": "tool_result", + "messages": [ + { + "tool_call_id": "old-1", + "name": "send_file_to_user", + "content": { + "type": "image", + "source": { + "type": "url", + "url": f"file://{ws}/{OUTBOUND_DIR}/prior.png", + "media_type": "image/png", + }, + "filename": "prior.png", + "path": f"{OUTBOUND_DIR}/prior.png", + }, + }, + ], + } + emitted_ids: set[str] = set() + emitted_keys: set[str] = set() + frames = [ + f + async for f in iter_dashboard_attachment_frames( + chunk, + agent_id="agent-1", + workspace=workspace, + saw_tool_call=False, + emitted_media_ids=emitted_ids, + emitted_attachment_keys=emitted_keys, + ) + ] + assert frames == [] + assert emitted_ids == set() + + frames_live = [ + f + async for f in iter_dashboard_attachment_frames( + chunk, + agent_id="agent-1", + workspace=workspace, + saw_tool_call=True, + emitted_media_ids=emitted_ids, + emitted_attachment_keys=emitted_keys, + ) + ] + assert len(frames_live) == 1 + assert emitted_ids == {"old-1"} + + # Same path via a new tool_call_id must not push again. + replay = { + "type": "tool_result", + "messages": [ + { + "tool_call_id": "new-2", + "name": "send_file_to_user", + "content": { + "type": "image", + "source": { + "type": "url", + "url": f"file://{ws}/{OUTBOUND_DIR}/prior.png", + "media_type": "image/png", + }, + "filename": "prior.png", + "path": f"{OUTBOUND_DIR}/prior.png", + }, + }, + ], + } + frames_dup_path = [ + f + async for f in iter_dashboard_attachment_frames( + replay, + agent_id="agent-1", + workspace=workspace, + saw_tool_call=True, + emitted_media_ids=emitted_ids, + emitted_attachment_keys=emitted_keys, + ) + ] + assert frames_dup_path == [] + + +def test_attachment_frame_dedup_key_prefers_path() -> None: + from octop.infra.gateway.media.tool_media import attachment_frame_dedup_key + + assert ( + attachment_frame_dedup_key( + {"path": "outbound/a.png", "preview_url": "/api/x", "url": "/api/y"} + ) + == "path:outbound/a.png" + ) + assert attachment_frame_dedup_key({"preview_url": "/api/x"}) == "url:/api/x" diff --git a/tests/unit/i18n/test_stream.py b/tests/unit/i18n/test_stream.py index cdc6f1cb6a..bbc76f292c 100644 --- a/tests/unit/i18n/test_stream.py +++ b/tests/unit/i18n/test_stream.py @@ -4,6 +4,7 @@ from octop.i18n.domains.stream import ( MODEL_CALL_FAILED, + PATH_OUTSIDE_ROOT, RECURSION_LIMIT, STREAM_STALL, classify_stream_error_message, @@ -98,6 +99,27 @@ def test_classify_model_call_failed_fallback() -> None: ) +def test_classify_path_outside_root() -> None: + msg = ( + r"Path:D:\octop-data\data\文章存稿\x.md outside root directory: " + r"C:\Users\Administrator" + ) + assert classify_stream_error_message(msg) == PATH_OUTSIDE_ROOT + assert classify_stream_error_message("Path traversal not allowed") == PATH_OUTSIDE_ROOT + + +def test_format_path_outside_root_zh_guides_to_storage_root() -> None: + msg = ( + r"ValueError: Path:D:\octop-data\data\文章存稿\_核验与备选标题.md " + r"outside root directory: C:\Users\Administrator" + ) + text = format_stream_error(msg, "zh") + assert "存储根目录" in text + assert "模型调用" not in text + assert "ValueError" not in text + assert "outside root" not in text + + def test_classify_unknown_passthrough() -> None: assert classify_stream_error_message("disk full") is None diff --git a/tests/unit/infra/utils/test_host_dirs.py b/tests/unit/infra/utils/test_host_dirs.py index fb98810bf1..5c092047e3 100644 --- a/tests/unit/infra/utils/test_host_dirs.py +++ b/tests/unit/infra/utils/test_host_dirs.py @@ -259,8 +259,7 @@ def test_host_fs_tree_root_admin_posix(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("octop.infra.utils.host_dirs.Path.home", lambda: home) from octop.infra.utils.host_dirs import host_fs_tree_root - assert host_fs_tree_root(allow_outside_home=True) == "/" - assert host_fs_tree_root(allow_outside_home=False) == home.resolve().as_posix() + assert host_fs_tree_root() == "/" def test_running_in_container_env_override(monkeypatch: pytest.MonkeyPatch) -> None: @@ -291,26 +290,6 @@ def is_file(self) -> bool: assert running_in_container() is True -def test_default_host_root_dir_uses_fs_root_in_container( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - from octop.infra.utils.host_dirs import default_host_root_dir, host_fs_tree_root - - home = tmp_path / "os_home" - home.mkdir() - monkeypatch.setattr("octop.infra.utils.host_dirs.Path.home", lambda: home) - - monkeypatch.setenv("OCTOP_IN_CONTAINER", "0") - assert default_host_root_dir(allow_outside_home=True) == home.resolve().as_posix() - - monkeypatch.setenv("OCTOP_IN_CONTAINER", "1") - assert default_host_root_dir(allow_outside_home=True) == host_fs_tree_root( - allow_outside_home=True - ) - # Policy / home-jail mode must not jump to filesystem root. - assert default_host_root_dir(allow_outside_home=False) == home.resolve().as_posix() - - def test_list_and_probe_return_posix_paths(tmp_path: Path) -> None: (tmp_path / "alpha").mkdir() entries = list_host_subdirs(str(tmp_path)) diff --git a/tests/unit/users/test_resource_policy.py b/tests/unit/users/test_resource_policy.py index 145f95e959..938af1504c 100644 --- a/tests/unit/users/test_resource_policy.py +++ b/tests/unit/users/test_resource_policy.py @@ -14,8 +14,10 @@ from octop.infra.db.repos.users import UserRepo from octop.infra.errors import ErrorCode, OctopError from octop.infra.users.resource_policy import ( + POLICY_MAX_AGENTS, POLICY_TOKEN_QUOTA, POLICY_WORKSPACE_ROOT_DIR, + assert_agent_quota_available, assert_backend_within_user_root, assert_token_quota_available, normalize_workspace_root_dir, @@ -113,6 +115,30 @@ def test_assert_token_quota_available(db: SqlitePool) -> None: assert_token_quota_available(policies, usage, uid) +def test_assert_agent_quota_available(db: SqlitePool) -> None: + users = UserRepo(db) + policies = UserPolicyRepo(db) + agents = AgentRepo(db) + uid = users.create(username="alice", password_hash="h", role="user") + assert_agent_quota_available(policies, agents, uid) + + policies.set(uid, POLICY_MAX_AGENTS, "1") + agents.create(agent_id="a1", user_id=uid, name="a", kind="expert") + with pytest.raises(OctopError) as exc: + assert_agent_quota_available(policies, agents, uid) + assert exc.value.code is ErrorCode.AGENT_QUOTA_EXCEEDED + assert exc.value.details == {"used": 1, "quota": 1} + + # Teams do not count toward the expert quota. + agents.create(agent_id="t1", user_id=uid, name="team", kind="team") + with pytest.raises(OctopError) as exc2: + assert_agent_quota_available(policies, agents, uid) + assert exc2.value.details["used"] == 1 + + policies.set(uid, POLICY_MAX_AGENTS, "2") + assert_agent_quota_available(policies, agents, uid) + + def test_user_policy_repo_named_rows(db: SqlitePool, tmp_path: Path) -> None: users = UserRepo(db) policies = UserPolicyRepo(db) @@ -131,6 +157,7 @@ def test_user_policy_repo_named_rows(db: SqlitePool, tmp_path: Path) -> None: assert public_policy_fields(policies.list_for_user(uid)) == { "workspace_root_dir": str(jail), "token_quota": 42, + "max_agents": None, } root = policies.get(uid, POLICY_WORKSPACE_ROOT_DIR) assert root is not None From 44023b192fa77bd6980a62ed43ce97c6c86e6e53 Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Thu, 24 Sep 2026 09:53:42 +0000 Subject: [PATCH 013/126] docs: point README at TencentCloud octop-harness repos The harness-* libraries are now published as octop-harness, octop-gateway, octop-memory, and octop-browser. Co-authored-by: Cursor --- README.md | 30 ++++++++++++++---------------- README_CN.md | 30 ++++++++++++++---------------- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 8e3a646f78..a660032add 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Chat through the Web Dashboard, Feishu, DingTalk, QQ, Telegram, WeCom, or progra | 🔒 | **Security built-in** | JWT multi-user isolation, tool approval, shell command guardrails, and PII redaction — data stays local | | 🔌 | **Connector ecosystem** | Tencent suite (Docs, Weibo trends, News, …); OAuth and MCP gateway extend resource boundaries | | 💾 | **Pluggable backends** | Local disk, Docker containers, PostgreSQL, or COS/S3 — AI operates inside isolated boundaries | -| 🧠 | **Portable memory** | Powered by harness-memory; memory migrates with the workspace | +| 🧠 | **Portable memory** | Powered by [octop-memory](https://github.com/TencentCloud/octop-memory); memory migrates with the workspace | | 📚 | **Knowledge base** | RAG over your documents; semantic retrieval grounds agent answers in your private corpus | | 🧩 | **Plugins** | Extend Octop with third-party plugins; bundled plugins are seeded and toggled on demand | | ↔️ | **ACP bidirectional** | `octop acp` for IDE/terminal AI; delegate to OpenCode / Claude Code with permission gates | @@ -85,20 +85,20 @@ Octop is a self-hosted AI assistant platform for households and small teams. It |-------|-----------| | **Language** | Python 3.12+ | | **Web framework** | FastAPI + uvicorn | -| **Agent runtime** | harness-agent | -| **Gateway** | harness-gateway | +| **Agent runtime** | [octop-harness](https://github.com/TencentCloud/octop-harness) | +| **Gateway** | [octop-gateway](https://github.com/TencentCloud/octop-gateway) | | **Control plane DB** | SQLite (WAL, default) or PostgreSQL (optional) | | **Frontend** | React 18 + TypeScript + Vite + Ant Design | | **Scheduling** | APScheduler | | **ACP** | agent-client-protocol | | **Build / quality** | hatchling · ruff · mypy · pytest | -Octop is built on the Harness stack — a set of focused runtimes that Octop composes into one process: +Octop is built on the Octop Harness stack — a set of focused runtimes that Octop composes into one process: -- **harness-agent** — Agent runtime: model routing, tools, skills, and conversation checkpointing. -- **harness-gateway** — multi-platform IM channel bridge that normalizes incoming messages into a single processing pipeline. -- **harness-memory** — hierarchical recall with full-text search, so an agent's memory travels with its workspace. -- **harness-browser** — CDP-based browser automation with persistent profiles for web tasks. +- **[octop-harness](https://github.com/TencentCloud/octop-harness)** — Agent runtime: model routing, tools, skills, and conversation checkpointing. +- **[octop-gateway](https://github.com/TencentCloud/octop-gateway)** — multi-platform IM channel bridge that normalizes incoming messages into a single processing pipeline. +- **[octop-memory](https://github.com/TencentCloud/octop-memory)** — hierarchical recall with full-text search, so an agent's memory travels with its workspace. +- **[octop-browser](https://github.com/TencentCloud/octop-browser)** — CDP-based browser automation with persistent profiles for web tasks. Instead of an external queue or message broker, Octop routes every surface — Web UI, IM, and cron — through one in-process `HarnessProcessor`. The result is a single, restart-safe process whose entire state is rebuilt from the control-plane database on boot (local SQLite by default; PostgreSQL optional). @@ -453,9 +453,9 @@ OctopServer ├─ UserManager │ └─ HarnessAgentManager (per user) │ └─ AgentRuntime (per agent) - │ ├─ HarnessAgent Agent runtime (harness-agent) + │ ├─ HarnessAgent Agent runtime (octop-harness) │ ├─ HarnessProcessor IM / UI / cron entry point - │ ├─ ChannelManager IM connections (harness-gateway) + │ ├─ ChannelManager IM connections (octop-gateway) │ └─ CronManager APScheduler └─ FastAPI app (uvicorn) ``` @@ -527,12 +527,10 @@ See [CHANGELOG.md](CHANGELOG.md) for release history. | Project | Description | |---------|-------------| -| harness-agent | Agent runtime — model routing, tools, skills, checkpointing | -| harness-gateway | Multi-platform IM channel bridge | -| harness-memory | Hierarchical recall and FTS search | -| harness-browser | CDP browser automation with persistent profiles | - -> These `harness-*` projects are being prepared for open-sourcing; repository links will be added once they are published. +| [octop-harness](https://github.com/TencentCloud/octop-harness) | Agent runtime — model routing, tools, skills, checkpointing | +| [octop-gateway](https://github.com/TencentCloud/octop-gateway) | Multi-platform IM channel bridge | +| [octop-memory](https://github.com/TencentCloud/octop-memory) | Hierarchical recall and FTS search | +| [octop-browser](https://github.com/TencentCloud/octop-browser) | CDP browser automation with persistent profiles | ## 💬 WeCom Customer Group (CN) diff --git a/README_CN.md b/README_CN.md index 4ee2e69837..1f0bef31ca 100644 --- a/README_CN.md +++ b/README_CN.md @@ -56,7 +56,7 @@ | 🔒 | **更安全** | JWT 多用户隔离、工具审批、Shell 命令防护与敏感信息脱敏,数据留在本地 | | 🔌 | **Connector 拓展体系** | 一键接入腾讯全家桶(文档 / 微博 / 新闻等),OAuth 与 MCP 网关轻松扩展 | | 💾 | **可插拔后端存储** | 本地目录、Docker 容器、PostgreSQL 或 COS/S3,AI 在隔离边界内操作 | -| 🧠 | **可迁移记忆系统** | 基于 harness-memory,记忆随工作区迁移 | +| 🧠 | **可迁移记忆系统** | 基于 [octop-memory](https://github.com/TencentCloud/octop-memory),记忆随工作区迁移 | | 📚 | **知识库** | 基于文档的 RAG 检索,让 Agent 的回答锚定你的私有知识库 | | 🧩 | **插件** | 支持第三方插件扩展;内置插件随安装注入,按需一键启用 | | ↔️ | **ACP 双向集成** | `octop acp` 增强 IDE 与终端 AI;对话中委派 OpenCode / Claude Code 等 | @@ -85,20 +85,20 @@ |------|------| | **语言** | Python 3.12+ | | **Web 框架** | FastAPI + uvicorn | -| **Agent 运行时** | harness-agent | -| **IM 桥接** | harness-gateway | +| **Agent 运行时** | [octop-harness](https://github.com/TencentCloud/octop-harness) | +| **IM 桥接** | [octop-gateway](https://github.com/TencentCloud/octop-gateway) | | **控制平面数据库** | SQLite (WAL,默认) 或 PostgreSQL(可选) | | **前端** | React 18 + TypeScript + Vite + Ant Design | | **调度** | APScheduler | | **ACP** | agent-client-protocol | | **构建 / 质量** | hatchling · ruff · mypy · pytest | -Octop 基于一系列 Harness 工程实践构建——它将这些专注的运行时组合进同一个进程: +Octop 基于一系列 Octop Harness 工程实践构建——它将这些专注的运行时组合进同一个进程: -- **harness-agent** — Agent 运行时:模型路由、工具、技能与对话检查点。 -- **harness-gateway** — 多平台 IM 通道桥接,将各类入站消息归一为统一的处理管线。 -- **harness-memory** — 分层记忆与全文检索,让 Agent 的记忆随工作区一同迁移。 -- **harness-browser** — 基于 CDP 的浏览器自动化,支持持久化配置,用于网页类任务。 +- **[octop-harness](https://github.com/TencentCloud/octop-harness)** — Agent 运行时:模型路由、工具、技能与对话检查点。 +- **[octop-gateway](https://github.com/TencentCloud/octop-gateway)** — 多平台 IM 通道桥接,将各类入站消息归一为统一的处理管线。 +- **[octop-memory](https://github.com/TencentCloud/octop-memory)** — 分层记忆与全文检索,让 Agent 的记忆随工作区一同迁移。 +- **[octop-browser](https://github.com/TencentCloud/octop-browser)** — 基于 CDP 的浏览器自动化,支持持久化配置,用于网页类任务。 Octop 不依赖外部消息队列或中间件,而是通过进程内的 `HarnessProcessor` 统一路由所有入口——Web UI、IM 与定时任务。最终呈现为一个可重启恢复的单进程:启动时整个状态都从控制面数据库重建(默认 `~/.octop/octop.db`,亦可配置 PostgreSQL)。 @@ -446,9 +446,9 @@ OctopServer ├─ UserManager │ └─ HarnessAgentManager(按用户) │ └─ AgentRuntime(按 Agent) - │ ├─ HarnessAgent Agent 运行时(harness-agent) + │ ├─ HarnessAgent Agent 运行时(octop-harness) │ ├─ HarnessProcessor IM / UI / 定时任务入口 - │ ├─ ChannelManager IM 连接(harness-gateway) + │ ├─ ChannelManager IM 连接(octop-gateway) │ └─ CronManager APScheduler └─ FastAPI app (uvicorn) ``` @@ -521,12 +521,10 @@ cd dashboard && npx tsc -b | 项目 | 描述 | |------|------| -| harness-agent | Agent 运行时 — 模型路由、工具、Skill、检查点 | -| harness-gateway | 多平台 IM 通道桥接 | -| harness-memory | 层级召回与全文搜索 | -| harness-browser | CDP 浏览器自动化,支持 profile 持久登录 | - -> 这些 `harness-*` 项目正在筹备开源中,仓库地址将在发布后补充。 +| [octop-harness](https://github.com/TencentCloud/octop-harness) | Agent 运行时 — 模型路由、工具、Skill、检查点 | +| [octop-gateway](https://github.com/TencentCloud/octop-gateway) | 多平台 IM 通道桥接 | +| [octop-memory](https://github.com/TencentCloud/octop-memory) | 层级召回与全文搜索 | +| [octop-browser](https://github.com/TencentCloud/octop-browser) | CDP 浏览器自动化,支持 profile 持久登录 | ## 💬 客户企业微信群 From 5e34c8a0011381b1b343deeaa4c10e11ef67e0c3 Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Thu, 24 Sep 2026 10:11:01 +0000 Subject: [PATCH 014/126] fix: connectors empty layout and restore prior control-plane behaviors Apply the remaining working-tree changes: connectors empty-state layout, plus the knowledge, HITL, captcha, workspace, user-cache, and SSRF adjustments with matching tests. Co-authored-by: Cursor --- CHANGELOG.md | 14 --- .../pages/Agent/Connectors/index.module.less | 17 ++-- .../src/pages/Agent/Connectors/index.tsx | 77 +++++++-------- docs/configuration.md | 2 +- src/octop/api/routers/chat/history.py | 10 +- src/octop/api/routers/knowledge_bases.py | 8 +- src/octop/api/routers/workspace.py | 4 +- .../infra/agents/builtin_skills/__init__.py | 19 +--- .../plugins/bundled/wiki-summary/main.py | 11 --- .../infra/agents/security/hitl_session.py | 28 +++--- src/octop/infra/auth/captcha/config.py | 4 - src/octop/infra/backup/workspace_archive.py | 15 +-- src/octop/infra/knowledge/service.py | 7 -- src/octop/infra/users/manager.py | 59 +++++------ src/octop/infra/utils/ssrf_guard.py | 7 +- tests/integration/test_captcha_api.py | 35 +------ tests/integration/test_thread_list_api.py | 48 --------- tests/integration/test_workspace_api.py | 28 ------ tests/unit/agents/test_hitl_session_policy.py | 71 +------------ tests/unit/api/test_knowledge_bases.py | 58 ----------- tests/unit/auth/test_captcha_env.py | 21 ---- tests/unit/backup/test_workspace_archive.py | 29 ------ tests/unit/knowledge/test_service_acl.py | 26 ----- tests/unit/test_user_manager.py | 60 ----------- tests/unit/test_wiki_summary_plugin.py | 99 ------------------- .../utils/test_ssrf_guard_pinned_request.py | 76 -------------- 26 files changed, 96 insertions(+), 737 deletions(-) delete mode 100644 tests/integration/test_thread_list_api.py delete mode 100644 tests/unit/test_wiki_summary_plugin.py delete mode 100644 tests/unit/utils/test_ssrf_guard_pinned_request.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 366a508bc2..095811131c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,20 +6,6 @@ ## [Unreleased] -### 修复 -- 知识库文档数达到上限时不再答非所问:单库 `max_documents` 可配置之后,上传超限文档仍按字面量「at most 100」匹配错误文案,只有上限恰好是默认值 100 的库才报对,其余(如 2、500)会返回 409 `KNOWLEDGE_BASE_LIMIT`「每个用户最多可创建 20 个知识库」;现按两条报错各自的稳定措辞区分,超限一律正确返回 `KNOWLEDGE_DOC_LIMIT` -- 知识库文档重命名保留存储键的文件后缀:改名去掉 `.pdf` 等后缀后,原文下载/预览会 404、删除也会留下孤儿文件;现按 `create_text_document` 的既有做法补回原后缀,目录改名不受影响 (#1107) -- 会话列表接口 `GET /api/agents/{id}/threads` 的 `limit` 增加 1–200 边界(与消息分页沿用同一上限):此前负数会被 SQLite 解释成「不限制」,一次返回该用户的全部会话,`limit=0` 又返回空列表;越界请求现在统一拒绝 - -- 填写 IPv6 字面量地址的 MCP / OAuth 端点此前无法连通:出站请求固定 IP 时会把 URL 重建为 httpx 无法解析的形式(`InvalidURL`),同时丢弃路径参数;现按原样保留两者 -- 会话级 HITL「跳过审批」不再按进程缓存:`octop run --workers N`(或 CLI 与服务并发)时,另一个进程里撤销的跳过仍会持续自动放行工具调用,新授予的也可能不生效;改为每次判定都以 `threads.hitl_policy` 为准 -- 用户角色与禁用/删除状态改为按请求读库:多 worker(`octop run --workers N`)或 CLI 离线改写同一 SQLite 时,在另一个进程里被降级、禁用或删除的用户立即失效(#1102) -- 导入工作区 zip 时跳过 Octop 自有的 `_builtin_skills/` 条目:该前缀不允许删除或移动,归档里的同名文件会留下用户无法移除的技能 -- 工作区写入接口补上 `_builtin_skills` 保护:`PUT /workspace/file` 和 `POST /workspace/upload` 此前可以往 Octop 内置技能目录里写文件(新建目录、删除、移动、文档编辑都拒绝),写进去的文件会被当作内置技能出现在技能列表里,而删除/移动接口对该前缀一律 403,所以再也清不掉 - -- `wiki_summary` 的 `lang` 不再被拼进请求主机名:此前传 `evil.com#` 会真的向 `https://evil.com` 发请求(`localhost:8443/` 则打本机端口),并把对方返回的摘要回显进聊天;现只接受 `zh` / `en` / `zh-classical` 这类裸子域标签,其余按「语言代码无效」返回错误卡片 -- 登录验证码的 `OCTOP_CAPTCHA_V3_MIN_SCORE` 只按 `float()` 解析,未校验取值:填成 `nan` 时 `score < nan` 恒为 `False`,`recaptcha-v3` 的分数门槛被静默关闭(机器分 0.0 也能登录),负数同样放行,`inf`/大于 1 则把所有登录锁死。现按该参数已有的「不可用即回落默认值」规则处理,只接受 `0`–`1` 内的有限数值 - ## [1.0.2b2] - 2026-09-23 ### 新增 diff --git a/dashboard/src/pages/Agent/Connectors/index.module.less b/dashboard/src/pages/Agent/Connectors/index.module.less index 932f3c0d65..48cea8e888 100644 --- a/dashboard/src/pages/Agent/Connectors/index.module.less +++ b/dashboard/src/pages/Agent/Connectors/index.module.less @@ -42,13 +42,16 @@ column-gap: 12px; } -.emptyGuideMascot { - display: block; - width: 120px; - height: 120px; - object-fit: contain; - user-select: none; - -webkit-user-drag: none; +.emptyLayout { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: auto; +} + +.emptyGuide { + flex: 1; } /* ── Type Card (service catalog) ───────────────────────────────── */ diff --git a/dashboard/src/pages/Agent/Connectors/index.tsx b/dashboard/src/pages/Agent/Connectors/index.tsx index 4398f9274e..7e5a1fda10 100644 --- a/dashboard/src/pages/Agent/Connectors/index.tsx +++ b/dashboard/src/pages/Agent/Connectors/index.tsx @@ -2212,45 +2212,44 @@ export default function ConnectorsPage() {

) : instances.length === 0 ? ( - - } - title={t("connectors.emptyGuideTitle")} - description={t("connectors.emptyGuideDesc")} - steps={[ - { - label: t("connectors.emptyGuideStepWhat"), - detail: t("connectors.emptyGuideStepWhatDetail"), - }, - { - label: t("connectors.emptyGuideStepHow"), - detail: t("connectors.emptyGuideStepHowDetail"), - }, - { - label: t("connectors.emptyGuideStepShare"), - detail: t("connectors.emptyGuideStepShareDetail"), - }, - ]} - primaryAction={{ - label: t("connectors.emptyGuideBrowseBuiltin"), - onClick: () => setActiveTab("builtin"), - icon: , - }} - secondaryAction={{ - label: t("connectors.emptyGuideAddCustom"), - onClick: () => { - setCustomFocusServerName(null); - setActiveTab("custom"); - }, - icon: , - type: "default", - }} - /> +
+ } + title={t("connectors.emptyGuideTitle")} + description={t("connectors.emptyGuideDesc")} + steps={[ + { + label: t("connectors.emptyGuideStepWhat"), + detail: t("connectors.emptyGuideStepWhatDetail"), + }, + { + label: t("connectors.emptyGuideStepHow"), + detail: t("connectors.emptyGuideStepHowDetail"), + }, + { + label: t("connectors.emptyGuideStepShare"), + detail: t("connectors.emptyGuideStepShareDetail"), + }, + ]} + primaryAction={{ + label: t("connectors.emptyGuideBrowseBuiltin"), + onClick: () => setActiveTab("builtin"), + icon: , + }} + secondaryAction={{ + label: t("connectors.emptyGuideAddCustom"), + onClick: () => { + setCustomFocusServerName(null); + setActiveTab("custom"); + }, + icon: , + type: "default", + }} + /> +
) : ( <>
diff --git a/docs/configuration.md b/docs/configuration.md index 92647beb60..5e93fc6db2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -147,7 +147,7 @@ Each variable, when set, takes precedence over the matching key in | `OCTOP_CAPTCHA_SECRET` | string | empty | Siteverify secret (Tencent: AppSecretKey; never logged; `GET /api/envs` redacts it) | | `OCTOP_CAPTCHA_CAM_SECRET_ID` | string | empty | Tencent only: CAM API SecretId signing `DescribeCaptchaResult`; required when the env snapshot is `tencent` | | `OCTOP_CAPTCHA_CAM_SECRET_KEY` | string | empty | Tencent only: CAM API SecretKey; never logged; `GET /api/envs` redacts it | -| `OCTOP_CAPTCHA_V3_MIN_SCORE` | float | `0.5` | Minimum `recaptcha-v3` score, in the provider's `0`–`1` range; values outside it (including `nan` / `inf`) fall back to the default, same as an unparsable one; admin UI is read-only | +| `OCTOP_CAPTCHA_V3_MIN_SCORE` | float | `0.5` | Minimum `recaptcha-v3` score; admin UI is read-only | | `OCTOP_DEFAULT_TIMEZONE` | IANA tz | `Asia/Shanghai` | Default timezone for display, scheduling, and harness (`cron_timezone` / `OCTOP_CRON_TIMEZONE` still accepted) | | `OCTOP_CORS_ORIGINS` | comma-sep list | empty | Permitted CORS origins for the dashboard / external callers | | `OCTOP_ENABLE_DASHBOARD` | bool | `true` | Serve the built React SPA at `/` | diff --git a/src/octop/api/routers/chat/history.py b/src/octop/api/routers/chat/history.py index 25daf5eff1..6fee8bd418 100644 --- a/src/octop/api/routers/chat/history.py +++ b/src/octop/api/routers/chat/history.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Any -from fastapi import APIRouter, Depends, Query, Request +from fastapi import APIRouter, Depends, Request from octop.api.common.agent import require_agent_row from octop.api.common.agent_workspace import resolve_agent_workspace_dir @@ -15,7 +15,6 @@ from octop.api.routers.chat.models import ForkThreadBody, RebindSessionBody, RenameThreadBody from octop.api.routers.chat.serialize import ( HISTORY_DEFAULT_LIMIT, - HISTORY_MAX_LIMIT, _backfill_thread_projection, _clamp_history_limit, _load_projected_thread_messages, @@ -100,12 +99,7 @@ def _require_thread( @router.get("/agents/{agent_id}/threads", summary="List threads") async def list_threads( agent_id: str, - limit: int = Query( - default=50, - ge=1, - le=HISTORY_MAX_LIMIT, - description=f"Maximum threads to return, between 1 and {HISTORY_MAX_LIMIT}.", - ), + limit: int = 50, as_user: int | None = None, user: Any = Depends(current_user), server: Any = Depends(get_server), diff --git a/src/octop/api/routers/knowledge_bases.py b/src/octop/api/routers/knowledge_bases.py index a2bee5dd3a..231dfdf82e 100644 --- a/src/octop/api/routers/knowledge_bases.py +++ b/src/octop/api/routers/knowledge_bases.py @@ -223,9 +223,7 @@ def _map_knowledge_error( ) ): return OctopError.localized(ErrorCode.KNOWLEDGE_PREREQUISITES_FAILED, locale) - if "support at most" in text: - # ``KnowledgeRepo.create_document`` embeds the per-base ``max_documents``, - # which is user-configurable — never match on a literal limit here. + if "at most 100" in text: return OctopError.localized(ErrorCode.KNOWLEDGE_DOC_LIMIT, locale) if "document size exceeds" in text: max_mb = _max_upload_mb(server) if server is not None else DEFAULT_MAX_UPLOAD_MB @@ -235,9 +233,7 @@ def _map_knowledge_error( details={"max_mb": max_mb}, max_mb=max_mb, ) - if "can own at most" in text: - # Distinct from the document-limit wording above, which also mentions - # "knowledge bases" ("knowledge bases support at most N documents"). + if "at most" in text and "knowledge bases" in text: return OctopError.localized(ErrorCode.KNOWLEDGE_BASE_LIMIT, locale) if "unsupported knowledge document content type" in text: return OctopError.localized(ErrorCode.KNOWLEDGE_UNSUPPORTED_TYPE, locale) diff --git a/src/octop/api/routers/workspace.py b/src/octop/api/routers/workspace.py index ee2bbc778e..875b5e750a 100644 --- a/src/octop/api/routers/workspace.py +++ b/src/octop/api/routers/workspace.py @@ -179,7 +179,6 @@ async def write_file( server: Any = Depends(get_server), ) -> dict[str, Any]: """Overwrite ``path`` with ``body.content`` (text).""" - _assert_workspace_mutable(path) ws = await require_running_workspace( agent_id, user=user, as_user=as_user, server=server, owner_only=True ) @@ -311,11 +310,10 @@ async def upload_file( server: Any = Depends(get_server), ) -> dict[str, Any]: """Upload a binary file via multipart ``file=@...``.""" - target = path or f"/{file.filename or 'upload.bin'}" - _assert_workspace_mutable(target) ws = await require_running_workspace( agent_id, user=user, as_user=as_user, server=server, owner_only=True ) + target = path or f"/{file.filename or 'upload.bin'}" data = await file.read() try: await ws.aupload_bytes( diff --git a/src/octop/infra/agents/builtin_skills/__init__.py b/src/octop/infra/agents/builtin_skills/__init__.py index d8cc26ebc6..8b73664596 100644 --- a/src/octop/infra/agents/builtin_skills/__init__.py +++ b/src/octop/infra/agents/builtin_skills/__init__.py @@ -14,19 +14,6 @@ _PACKAGE = "octop.infra.agents.builtin_skills" -def is_octop_builtin_skills_path(rel: str) -> bool: - """True for workspace-relative paths inside the Octop-owned built-in Skills root. - - Mirrors the two spellings ``_assert_workspace_mutable`` guards: the API name, and - the ``.octop/``-prefixed system location newer agents keep it in. - """ - posix = str(rel).replace("\\", "/").strip("/") - for prefix in (OCTOP_BUILTIN_SKILLS_ROOT, f".octop/{OCTOP_BUILTIN_SKILLS_ROOT}"): - if posix == prefix or posix.startswith(f"{prefix}/"): - return True - return False - - def _collect_files(source: Traversable, prefix: str, out: list[tuple[str, bytes]]) -> None: for entry in source.iterdir(): if entry.name.startswith((".", "__")): @@ -87,8 +74,4 @@ async def sync_octop_builtin_skills(workspace: Any) -> list[str]: return sorted(skill_names) -__all__ = [ - "OCTOP_BUILTIN_SKILLS_ROOT", - "is_octop_builtin_skills_path", - "sync_octop_builtin_skills", -] +__all__ = ["OCTOP_BUILTIN_SKILLS_ROOT", "sync_octop_builtin_skills"] diff --git a/src/octop/infra/agents/plugins/bundled/wiki-summary/main.py b/src/octop/infra/agents/plugins/bundled/wiki-summary/main.py index 6c400de2b3..266edad30e 100644 --- a/src/octop/infra/agents/plugins/bundled/wiki-summary/main.py +++ b/src/octop/infra/agents/plugins/bundled/wiki-summary/main.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import re from typing import Any from urllib.parse import quote @@ -12,11 +11,6 @@ _UA = "Octop-wiki-summary/0.1.0" -# ``lang`` is interpolated into the URL host, so it must stay a bare subdomain label: -# ``evil.com#`` would otherwise send the request to evil.com, and ``localhost:8443/`` -# to the server's own loopback. -_LANG = re.compile(r"[a-z][a-z-]{1,14}") - def _payload(data: dict[str, Any], text: str) -> str: return json.dumps( @@ -65,11 +59,6 @@ async def wiki_summary(query: str, lang: str = "zh") -> str: if not q: return _payload({"error": "empty"}, "请提供词条名或关键词。") language = (lang or "zh").strip().lower() or "zh" - if not _LANG.fullmatch(language): - return _payload( - {"error": "invalid lang", "query": q, "lang": language}, - "语言代码无效:lang 只接受 zh、en、ja、zh-classical 这样的维基百科语言子域。", - ) headers = {"User-Agent": _UA} try: with httpx.Client(timeout=20.0, headers=headers, follow_redirects=True) as client: diff --git a/src/octop/infra/agents/security/hitl_session.py b/src/octop/infra/agents/security/hitl_session.py index c14d9080fb..10099097d1 100644 --- a/src/octop/infra/agents/security/hitl_session.py +++ b/src/octop/infra/agents/security/hitl_session.py @@ -150,41 +150,35 @@ def current_hitl_thread_id() -> str | None: class HitlSessionPolicyStore: - """Thread bypass policy, read from ``threads.hitl_policy`` on every check. - - The row is the only state a wired store keeps: ``octop run --workers N`` - (and a CLI run beside a running server) share one database, so a - process-local read-through cache would keep auto-approving tool calls after - another process revoked the bypass — and hide one another process granted. - ``_unpersisted`` is the storage for a store built without a repo (nothing - to read back from), never a cache of persisted rows. - """ + """In-memory cache of thread bypass policy, persisted on ``threads.hitl_policy``.""" def __init__(self, threads_repo: ThreadRepo | None = None) -> None: self._repo = threads_repo - self._unpersisted: dict[str, HitlSessionPolicy] = {} + self._cache: dict[str, HitlSessionPolicy] = {} def replace_repo(self, threads_repo: ThreadRepo | None) -> None: self._repo = threads_repo - self._unpersisted.clear() + self._cache.clear() def get(self, thread_id: str) -> HitlSessionPolicy: tid = (thread_id or "").strip() if not tid: return HitlSessionPolicy() - if self._repo is None: - return self._unpersisted.get(tid) or HitlSessionPolicy() - return self._load(tid) + cached = self._cache.get(tid) + if cached is not None: + return cached + policy = self._load(tid) + self._cache[tid] = policy + return policy def set(self, thread_id: str, policy: HitlSessionPolicy | object) -> HitlSessionPolicy: tid = (thread_id or "").strip() resolved = parse_hitl_session_policy(policy) if not tid: return resolved + self._cache[tid] = resolved repo = self._repo - if repo is None: - self._unpersisted[tid] = resolved - else: + if repo is not None: repo.update_composer(tid, hitl_policy=resolved.to_json()) return resolved diff --git a/src/octop/infra/auth/captcha/config.py b/src/octop/infra/auth/captcha/config.py index a46432fd41..6ca572bc03 100644 --- a/src/octop/infra/auth/captcha/config.py +++ b/src/octop/infra/auth/captcha/config.py @@ -42,10 +42,6 @@ def snapshot_env(environ: Mapping[str, str] | None = None) -> CaptchaEnv: v3_min_score = float(score_raw) if score_raw else _DEFAULT_V3_MIN_SCORE except ValueError: v3_min_score = _DEFAULT_V3_MIN_SCORE - # recaptcha-v3 scores are in [0, 1]: outside it the gate is either silently off - # (``score < nan`` is always False) or locked shut, so fall back like an unparsable value. - if not 0.0 <= v3_min_score <= 1.0: - v3_min_score = _DEFAULT_V3_MIN_SCORE return CaptchaEnv( provider=provider, site_key=(src.get("OCTOP_CAPTCHA_SITE_KEY") or "").strip(), diff --git a/src/octop/infra/backup/workspace_archive.py b/src/octop/infra/backup/workspace_archive.py index 427a0fbcdc..eb6a0167c2 100644 --- a/src/octop/infra/backup/workspace_archive.py +++ b/src/octop/infra/backup/workspace_archive.py @@ -12,11 +12,6 @@ from harness_agent.backends.utils import materialize_storage_path -from octop.infra.agents.builtin_skills import ( - OCTOP_BUILTIN_SKILLS_ROOT, - is_octop_builtin_skills_path, -) - if TYPE_CHECKING: from harness_agent.backends.workspace import BackendWorkspace @@ -250,15 +245,7 @@ async def import_workspace_zip( "replace mode cleared only the local harness workspace; remote-only files may remain" ) - # ``DELETE`` / ``move`` refuse this prefix, so writing it from an archive would - # leave entries the user cannot remove; the files Octop owns are re-seeded on - # every agent start anyway. - pairs = [(rel, blob) for rel, blob in entries if not is_octop_builtin_skills_path(rel)] - skipped = len(entries) - len(pairs) - if skipped: - warnings.append( - f"skipped {skipped} {OCTOP_BUILTIN_SKILLS_ROOT!r} entry/entries (owned by Octop)" - ) + pairs = [(rel_path, blob) for rel_path, blob in entries] if pairs: await workspace.aupload_many(pairs) diff --git a/src/octop/infra/knowledge/service.py b/src/octop/infra/knowledge/service.py index 945b7acbdc..1c153e1f24 100644 --- a/src/octop/infra/knowledge/service.py +++ b/src/octop/infra/knowledge/service.py @@ -439,13 +439,6 @@ def rename_document( cleaned = (new_name or "").strip() if not cleaned or "/" in cleaned or "\\" in cleaned: raise ValueError("invalid knowledge document name") - if not document.is_dir: - # ``files.document_path`` keys the stored bytes on ``{doc_id}{suffix}``, so a - # rename that drops or swaps the extension moves the row off its own file: - # download and preview 404 and delete can no longer reclaim the bytes. - suffix = Path(document.filename).suffix.lower() - if suffix and Path(cleaned).suffix.lower() != suffix: - cleaned = f"{Path(cleaned).stem}{suffix}" new_path = normalize_kb_path(f"{path_parent(document.path)}/{cleaned}") if new_path == document.path: return cast(KnowledgeDocumentRow, document) diff --git a/src/octop/infra/users/manager.py b/src/octop/infra/users/manager.py index 4e81333b64..ed9d2ba67e 100644 --- a/src/octop/infra/users/manager.py +++ b/src/octop/infra/users/manager.py @@ -19,7 +19,7 @@ from octop.infra.db.repos._base import UNSET from octop.infra.db.repos.audit import ACTOR_ADMIN -from octop.infra.db.repos.users import UserRepo, UserRow +from octop.infra.db.repos.users import UserRepo from octop.infra.db.services import SharedServices from octop.infra.errors import ErrorCode, OctopError from octop.infra.users.email import normalize_email, parse_optional_email @@ -79,17 +79,6 @@ def _is_unique_violation(exc: BaseException) -> bool: return pg_errors is not None and isinstance(exc, pg_errors.UniqueViolation) -def _user_from_row(row: UserRow) -> User: - return User( - id=row.id, - username=row.username, - role=Role(row.role), - display_name=row.display_name, - locale=normalize_locale(row.locale), - permissions=list(row.permissions), - ) - - class UserManager: """Owns the in-memory dict of ``User`` objects. @@ -114,7 +103,15 @@ def replace_services(self, services: SharedServices) -> None: async def boot(self) -> None: async with self._lock: for row in self._services.user_repo.list(include_disabled=False): - self._users[row.username] = _user_from_row(row) + user = User( + id=row.id, + username=row.username, + role=Role(row.role), + display_name=row.display_name, + locale=normalize_locale(row.locale), + permissions=list(row.permissions), + ) + self._users[row.username] = user async def shutdown_all(self) -> None: async with self._lock: @@ -216,25 +213,10 @@ def get(self, username: str) -> User | None: return self._users.get(username) def get_by_id(self, user_id: int) -> User | None: - """Resolve from the stored row, not this process's mirror. - - Every authenticated request reads through here (``api/deps.py``), and - ``octop run --workers N`` / an offline ``octop user`` CLI write lets another - process change the row. ``disable`` and ``remove`` already clear the local - mirror, so this keeps single-worker behaviour and fixes the cross-process one. - """ - row = self._services.user_repo.get(user_id) - if row is None or row.disabled: - self._evict_cached_id(user_id) - return None - user = _user_from_row(row) - self._users[row.username] = user - return user - - def _evict_cached_id(self, user_id: int) -> None: - for username, cached in list(self._users.items()): - if cached.id == user_id: - del self._users[username] + for u in self._users.values(): + if u.id == user_id: + return u + return None def get_row(self, user_id: int) -> Any: """Return the raw UserRow (includes disabled flag) for admin read operations.""" @@ -497,8 +479,17 @@ async def authenticate(self, username: str, password: str) -> User | None: ) return None self._services.user_repo.clear_login_lockout(row.id) - user = _user_from_row(row) - self._users[row.username] = user + user = self._users.get(row.username) + if user is None: + user = User( + id=row.id, + username=row.username, + role=Role(row.role), + display_name=row.display_name, + locale=normalize_locale(row.locale), + permissions=list(row.permissions), + ) + self._users[row.username] = user self._services.audit_repo.write(actor=row.username, action="auth.login") return user diff --git a/src/octop/infra/utils/ssrf_guard.py b/src/octop/infra/utils/ssrf_guard.py index cdd80dc3a7..a1c86bedf0 100644 --- a/src/octop/infra/utils/ssrf_guard.py +++ b/src/octop/infra/utils/ssrf_guard.py @@ -214,11 +214,8 @@ async def safe_request( pin_ip = await _resolve_validated_ip(url) # Rebuild the request URL from validated components so the outbound call # does not reuse the original user-controlled string (CWE-918 / CodeQL). - # ``hostname`` already strips the brackets an IPv6 literal needs in a URL, - # and the path parameter lives in its own component, so both are restored. - host_part = f"[{host}]" if ":" in host else host - netloc = f"{host_part}:{port}" if port is not None else host_part - request_url = urlunparse(("https", netloc, parsed.path or "/", parsed.params, parsed.query, "")) + netloc = f"{host}:{port}" if port is not None else host + request_url = urlunparse(("https", netloc, parsed.path or "/", "", parsed.query, "")) transport = PinnedIPTransport(host, pin_ip) async with httpx.AsyncClient(transport=transport, timeout=timeout) as client: return await client.request(method, request_url, json=json, data=data, headers=headers) diff --git a/tests/integration/test_captcha_api.py b/tests/integration/test_captcha_api.py index 59a442f63c..33bc4c9383 100644 --- a/tests/integration/test_captcha_api.py +++ b/tests/integration/test_captcha_api.py @@ -9,8 +9,7 @@ import pytest -from octop.infra.auth.captcha import config as captcha_config -from octop.infra.auth.captcha import set_test_siteverify_url, snapshot_env +from octop.infra.auth.captcha import set_test_siteverify_url from octop.infra.auth.captcha.store import SETTINGS_KEY from octop.infra.auth.sso.crypto import encrypt_secret from tests.support.auth import bootstrap_admin @@ -57,7 +56,6 @@ def siteverify() -> tuple[str, type[_Siteverify]]: server.server_close() set_test_siteverify_url("turnstile", None) set_test_siteverify_url("tencent", None) - set_test_siteverify_url("recaptcha-v3", None) @pytest.fixture @@ -151,37 +149,6 @@ async def test_captcha_fail_does_not_increment_lockout(client: Any, siteverify: assert int(row.login_failed_count or 0) == 0 -async def test_recaptcha_v3_gate_still_rejects_bot_score_when_env_is_unusable( - client: Any, - siteverify: Any, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """A non-numeric ``OCTOP_CAPTCHA_V3_MIN_SCORE`` must not disable the score gate.""" - url, handler = siteverify - set_test_siteverify_url("recaptcha-v3", url) - handler.payload = {"success": True, "score": 0.0, "action": "login"} - monkeypatch.setattr( - captcha_config, - "_INSTALLED", - snapshot_env( - { - "OCTOP_CAPTCHA_PROVIDER": "recaptcha-v3", - "OCTOP_CAPTCHA_SITE_KEY": "0xsite", - "OCTOP_CAPTCHA_SECRET": "test-secret", - "OCTOP_CAPTCHA_V3_MIN_SCORE": "nan", - } - ), - ) - c, _, home = client - await bootstrap_admin(c, home, username="alice", password="TestPass12") - r = await c.post( - "/api/auth/login", - json={"username": "alice", "password": "TestPass12", "captcha_token": "bot-token"}, - ) - assert r.status_code == 400 - assert r.json()["error"]["code"] == "CAPTCHA_FAILED" - - async def test_tencent_login_mocked_ok_returns_jwt(client: Any, siteverify: Any) -> None: url, handler = siteverify set_test_siteverify_url("tencent", url) diff --git a/tests/integration/test_thread_list_api.py b/tests/integration/test_thread_list_api.py deleted file mode 100644 index 25f51e8274..0000000000 --- a/tests/integration/test_thread_list_api.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Page bounds for ``GET /api/agents/{agent_id}/threads``.""" - -from __future__ import annotations - -from typing import Any - -from octop.api.routers.chat.serialize import HISTORY_MAX_LIMIT - - -async def _create_threads(client: Any, auth: dict[str, str], agent_id: str, count: int) -> None: - for _ in range(count): - response = await client.post(f"/api/agents/{agent_id}/threads", headers=auth) - assert response.status_code == 201, response.text - - -async def test_thread_list_rejects_limit_below_one(env_alice_bob_agent: Any) -> None: - """A non-positive ``limit`` must not be handed to SQL ``LIMIT``. - - SQLite reads a negative LIMIT as "no limit at all" and ``LIMIT 0`` as "no rows", - so both answer a paginated request with the wrong page instead of rejecting it. - """ - client, _srv, alice, _bob, agent_id = env_alice_bob_agent - await _create_threads(client, alice, agent_id, 3) - - for bad in (0, -1): - response = await client.get(f"/api/agents/{agent_id}/threads?limit={bad}", headers=alice) - assert response.status_code == 422, ( - f"limit={bad} -> {response.status_code}: {response.text}" - ) - - -async def test_thread_list_caps_limit_like_the_message_page( - env_alice_bob_agent: Any, -) -> None: - """The thread ceiling is the one this module already uses for message pages.""" - client, _srv, alice, _bob, agent_id = env_alice_bob_agent - await _create_threads(client, alice, agent_id, 2) - - ok = await client.get( - f"/api/agents/{agent_id}/threads?limit={HISTORY_MAX_LIMIT}", headers=alice - ) - assert ok.status_code == 200, ok.text - assert len(ok.json()) == 2 - - too_big = await client.get( - f"/api/agents/{agent_id}/threads?limit={HISTORY_MAX_LIMIT + 1}", headers=alice - ) - assert too_big.status_code == 422, too_big.text diff --git a/tests/integration/test_workspace_api.py b/tests/integration/test_workspace_api.py index b4f123b237..24cf69b9ef 100644 --- a/tests/integration/test_workspace_api.py +++ b/tests/integration/test_workspace_api.py @@ -408,34 +408,6 @@ async def test_delete_builtin_skills_forbidden(env: Any) -> None: assert r.status_code == 403 -@pytest.mark.parametrize( - "path", - ["/_builtin_skills/foo/SKILL.md", "/.octop/_builtin_skills/foo/SKILL.md"], -) -async def test_write_builtin_skills_forbidden(env: Any, path: str) -> None: - c, _srv, auth, aid = env - r = await c.put( - f"/api/agents/{aid}/workspace/file", - params={**FROM_WORKSPACE, "path": path}, - headers=auth, - json={"content": "# injected\n"}, - ) - assert r.status_code == 403 - - -async def test_upload_builtin_skills_forbidden(env: Any) -> None: - c, srv, auth, aid = env - r = await c.post( - f"/api/agents/{aid}/workspace/upload", - params={**FROM_WORKSPACE, "path": "/_builtin_skills/foo/SKILL.md"}, - headers=auth, - files={"file": ("SKILL.md", b"# injected\n", "text/markdown")}, - ) - assert r.status_code == 403 - agent = srv.app_runtime.agent_registry.get_agent(aid) - assert await agent.workspace.aexists("_builtin_skills/foo/SKILL.md") is False - - # --- editable document (Markdown round-trip) -------------------------------- diff --git a/tests/unit/agents/test_hitl_session_policy.py b/tests/unit/agents/test_hitl_session_policy.py index 8316e4b86c..1b24826799 100644 --- a/tests/unit/agents/test_hitl_session_policy.py +++ b/tests/unit/agents/test_hitl_session_policy.py @@ -21,7 +21,6 @@ parse_hitl_session_policy, thread_id_from_request, ) -from octop.infra.db.repos.threads import ThreadRepo @pytest.fixture @@ -101,15 +100,13 @@ def test_wrap_respects_original_when() -> None: def test_store_set_persists_once() -> None: repo = MagicMock() - repo.get.return_value = SimpleNamespace( - hitl_policy='{"mode": "allow_tools", "tools": ["bash"]}' - ) store = HitlSessionPolicyStore(repo) store.set("thr_1", {"mode": "allow_tools", "tools": ["bash"]}) repo.update_composer.assert_called_once_with( "thr_1", hitl_policy='{"mode": "allow_tools", "tools": ["bash"]}' ) assert store.get("thr_1").allows("bash") + repo.get.assert_not_called() @pytest.mark.asyncio @@ -135,72 +132,6 @@ async def fake_resume(agent_id: str, thread_id: str, decisions: list[dict[str, A assert current_hitl_thread_id() is None -@pytest.fixture -def thread_repo(tmp_path: Path) -> ThreadRepo: - from octop.infra.db.migrate import run_migrations - from octop.infra.db.pool import SqlitePool - from octop.infra.db.repos.agents import AgentRepo - from octop.infra.db.repos.users import UserRepo - - db = SqlitePool(tmp_path / "octop.db") - run_migrations(db) - UserRepo(db).create(username="u", password_hash="h", role="user") - AgentRepo(db).create(agent_id="a1", user_id=1, name="Agent 1") - repo = ThreadRepo(db) - repo.insert( - thread_id="thr_1", - agent_id="a1", - user_id=1, - channel_type="dashboard", - session_key="k", - ) - return repo - - -def test_revoked_bypass_stops_in_the_other_worker(thread_repo: ThreadRepo) -> None: - """Two ``octop run --workers`` processes share one DB: a revoke must land at once. - - A process-local cache kept auto-approving tool calls in the worker that had - read the old value, even though ``threads.hitl_policy`` was already cleared. - """ - serving_api = HitlSessionPolicyStore(thread_repo) - running_turn = HitlSessionPolicyStore(thread_repo) - - serving_api.set("thr_1", HitlSessionPolicy(mode="allow_all")) - assert running_turn.allows("thr_1", "execute") - - serving_api.set("thr_1", HitlSessionPolicy()) - assert thread_repo.get("thr_1").hitl_policy is None - assert not running_turn.allows("thr_1", "execute") - - -def test_granted_bypass_reaches_the_other_worker(thread_repo: ThreadRepo) -> None: - """The mirror case: a worker that read ``ask`` first must see a later grant.""" - serving_api = HitlSessionPolicyStore(thread_repo) - running_turn = HitlSessionPolicyStore(thread_repo) - - assert running_turn.get("thr_1").mode == "ask" - serving_api.set("thr_1", HitlSessionPolicy(mode="allow_tools", tools=["execute"])) - assert running_turn.allows("thr_1", "execute") - - -def test_bypass_is_not_active_when_persisting_failed( - thread_repo: ThreadRepo, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """``set`` wrote the value in memory before the row, so a locked DB left an - unpersisted bypass active while the API still reported ``ask``.""" - - def _locked(*_args: object, **_kwargs: object) -> None: - raise RuntimeError("database is locked") - - monkeypatch.setattr(thread_repo, "update_composer", _locked) - store = HitlSessionPolicyStore(thread_repo) - with pytest.raises(RuntimeError): - store.set("thr_1", HitlSessionPolicy(mode="allow_all")) - assert not store.allows("thr_1", "execute") - - def test_build_harness_config_skips_interrupt_for_allowed_thread( manager: AgentManager, ) -> None: diff --git a/tests/unit/api/test_knowledge_bases.py b/tests/unit/api/test_knowledge_bases.py index 5e0e48d5a6..d5069bfe8c 100644 --- a/tests/unit/api/test_knowledge_bases.py +++ b/tests/unit/api/test_knowledge_bases.py @@ -11,7 +11,6 @@ from starlette.requests import Request from octop.infra.errors import ErrorCode, OctopError -from octop.infra.knowledge.service import MAX_BASES_PER_OWNER, MAX_DOCS_PER_KB def _request() -> Request: @@ -733,60 +732,3 @@ def test_map_knowledge_error_prerequisites_distinguished() -> None: ) assert err_model.code == ErrorCode.KNOWLEDGE_PREREQUISITES_FAILED assert err_model.status == 409 - - -def test_custom_document_cap_is_not_reported_as_base_cap( - tmp_path: Path, -) -> None: - """Hitting a per-base ``max_documents`` must not come back as ``KNOWLEDGE_BASE_LIMIT``. - - ``KnowledgeRepo.create_document`` interpolates the configurable cap into its - message, so the router cannot key off a literal limit. - """ - from octop.api.routers.knowledge_bases import _map_knowledge_error - from octop.infra.db.migrate import run_migrations - from octop.infra.db.pool import SqlitePool - from octop.infra.db.repos.knowledge import KnowledgeRepo - from octop.infra.db.repos.users import UserRepo - - pool = SqlitePool(tmp_path / "octop.db") - run_migrations(pool) - owner_id = UserRepo(pool).create(username="owner", password_hash="h", role="user") - repo = KnowledgeRepo(pool) - cap = 2 - assert cap != MAX_DOCS_PER_KB - kb = repo.create_base(owner_user_id=owner_id, name="Capped", max_documents=cap) - - overflow: ValueError | None = None - for index in range(cap + 1): - try: - repo.create_document( - kb_id=kb.id, - filename=f"doc-{index}.md", - content_type="text/markdown", - byte_size=2, - max_documents=cap, - ) - except ValueError as exc: - overflow = exc - break - - assert overflow is not None, f"cap of {cap} was not enforced: {overflow}" - assert str(cap) in str(overflow) - - err = _map_knowledge_error(overflow, locale="en") - assert err.code == ErrorCode.KNOWLEDGE_DOC_LIMIT - assert err.status == 409 - - -def test_owner_base_cap_still_maps_to_base_limit() -> None: - from octop.api.routers.knowledge_bases import _map_knowledge_error - - message = f"a user can own at most {MAX_BASES_PER_OWNER} knowledge bases" - err = _map_knowledge_error(ValueError(message), locale="en") - assert err.code == ErrorCode.KNOWLEDGE_BASE_LIMIT - assert err.status == 409 - - err_zh = _map_knowledge_error(ValueError(message), locale="zh") - assert err_zh.code == ErrorCode.KNOWLEDGE_BASE_LIMIT - assert "知识库" in err_zh.message diff --git a/tests/unit/auth/test_captcha_env.py b/tests/unit/auth/test_captcha_env.py index b23c5ad50c..a0d11dfaad 100644 --- a/tests/unit/auth/test_captcha_env.py +++ b/tests/unit/auth/test_captcha_env.py @@ -61,24 +61,3 @@ def test_installed_snapshot_ignores_later_os_environ_changes( monkeypatch.setenv("OCTOP_CAPTCHA_SITE_KEY", "0xsite") monkeypatch.setenv("OCTOP_CAPTCHA_SECRET", "0xsecret") assert current_env().provider == "slider" - - -@pytest.mark.parametrize( - ("raw", "expected"), - [("", 0.5), ("abc", 0.5), ("0", 0.0), ("0.9", 0.9), ("1", 1.0)], -) -def test_min_score_snapshot_keeps_in_domain_values(raw: str, expected: float) -> None: - env = snapshot_env({"OCTOP_CAPTCHA_V3_MIN_SCORE": raw}) - assert env.v3_min_score == expected - - -@pytest.mark.parametrize("raw", ["nan", "NaN", "-nan", "inf", "-inf", "1e999"]) -def test_non_finite_min_score_falls_back_to_default(raw: str) -> None: - env = snapshot_env({"OCTOP_CAPTCHA_V3_MIN_SCORE": raw}) - assert env.v3_min_score == 0.5 - - -@pytest.mark.parametrize("raw", ["-0.1", "1.5"]) -def test_out_of_score_domain_min_score_falls_back_to_default(raw: str) -> None: - env = snapshot_env({"OCTOP_CAPTCHA_V3_MIN_SCORE": raw}) - assert env.v3_min_score == 0.5 diff --git a/tests/unit/backup/test_workspace_archive.py b/tests/unit/backup/test_workspace_archive.py index f9aff5ec47..b24f701704 100644 --- a/tests/unit/backup/test_workspace_archive.py +++ b/tests/unit/backup/test_workspace_archive.py @@ -237,32 +237,3 @@ async def test_export_mountless_includes_hidden_via_aglob(tmp_path: Path) -> Non assert names == {"SOUL.md", ".octop/sessions/x.db", "outbound/a.txt"} assert zf.read(".octop/sessions/x.db") == b"db" assert zf.read("outbound/a.txt") == b"out" - - -@pytest.mark.asyncio -async def test_import_skips_octop_builtin_skills(tmp_path: Path) -> None: - """An archive must not plant entries under the Octop-owned ``_builtin_skills`` root. - - ``DELETE`` / ``move`` refuse that prefix, so a planted skill directory would be - something the user cannot remove through the API, while ``sync_octop_builtin_skills`` - only prunes the names it retired itself. - """ - ws = tmp_path / "ws" - ws.mkdir() - backend = LocalShellBackend(root_dir=str(ws), virtual_mode=False) - workspace = BackendWorkspace(backend, ws) - - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w") as zf: - zf.writestr("notes.md", "keep me") - zf.writestr("_builtin_skills/evil/SKILL.md", "plant") - zf.writestr(".octop/_builtin_skills/evil/SKILL.md", "plant") - data = buf.getvalue() - - result = await import_workspace_zip(workspace, data, mode="merge", local_workspace_dir=None) - - assert (ws / "notes.md").read_bytes() == b"keep me" - assert not (ws / "_builtin_skills").exists() - assert not (ws / ".octop" / "_builtin_skills").exists() - assert result["imported"] == 1 - assert any("_builtin_skills" in warning for warning in result["warnings"]) diff --git a/tests/unit/knowledge/test_service_acl.py b/tests/unit/knowledge/test_service_acl.py index a680ca396c..7c6bdcc05e 100644 --- a/tests/unit/knowledge/test_service_acl.py +++ b/tests/unit/knowledge/test_service_acl.py @@ -277,29 +277,3 @@ def test_update_base_validates_max_documents_range(service: KnowledgeService) -> # Omit keeps value service.update_base(kb.id, actor_user_id=owner, description="keep") assert service.get_readable_base(kb.id, actor_user_id=owner).max_documents == 10_000 - - -def test_rename_keeps_the_suffix_that_keys_the_stored_bytes( - service: KnowledgeService, -) -> None: - from octop.infra.knowledge.files import document_path - - owner = service._services.user_repo.create(username="owner", password_hash="h", role="user") - kb = service.create_base(owner_user_id=owner, name="Docs") - doc = service.upload_document( - kb.id, - actor_user_id=owner, - filename="report.pdf", - content_type="application/pdf", - content=b"%PDF-1.4", - ) - stored = document_path(kb.id, doc.id, doc.filename) - assert stored.is_file() - - renamed = service.rename_document( - kb.id, doc.id, new_name="quarterly report", actor_user_id=owner - ) - - assert renamed.filename.lower().endswith(".pdf") - assert service.document_has_original(renamed) is True - assert stored.is_file() diff --git a/tests/unit/test_user_manager.py b/tests/unit/test_user_manager.py index 2bc0d7dbe0..745129ba23 100644 --- a/tests/unit/test_user_manager.py +++ b/tests/unit/test_user_manager.py @@ -470,63 +470,3 @@ async def test_boot_loads_users(tmp_path: Path): assert not hasattr(user, "agent_manager") or True # just verify no crash finally: await manager.shutdown_all() - - -async def _two_workers(tmp_path: Path) -> tuple[UserManager, UserManager]: - """Two ``UserManager``s over one DB file = ``octop run --workers 2``.""" - paths = PathLayout(tmp_path / ".octop") - paths.ensure_root() - run_migrations(SqlitePool(paths.db)) - workers = [] - for _ in range(2): - services = build_shared_services(db=SqlitePool(paths.db), paths=paths, config=OctopConfig()) - workers.append(UserManager(services)) - return workers[0], workers[1] - - -async def test_set_role_visible_to_other_worker(tmp_path: Path): - """A revoked admin must stop being admin on the worker that did not serve the call.""" - writer, other = await _two_workers(tmp_path) - created = await writer.create(username="alice", password="TestPass12", role=Role.ADMIN) - await other.boot() - assert other.get_by_id(created.id).role is Role.ADMIN - - await writer.set_role("alice", Role.USER) - assert writer.get_by_id(created.id).role is Role.USER - assert other.get_by_id(created.id).role is Role.USER - assert other.get_by_id(created.id).is_admin is False - - -async def test_disable_revokes_other_worker(tmp_path: Path): - """A disabled user must stop resolving by id on every worker, not just this one.""" - writer, other = await _two_workers(tmp_path) - created = await writer.create(username="bob", password="TestPass12", role=Role.ADMIN) - await other.boot() - assert other.get_by_id(created.id) is not None - - await writer.disable("bob") - assert writer.get_by_id(created.id) is None - assert other.get_by_id(created.id) is None - - -async def test_remove_revokes_other_worker(tmp_path: Path): - """A deleted user must stop resolving by id on every worker.""" - writer, other = await _two_workers(tmp_path) - created = await writer.create(username="carol", password="TestPass12", role=Role.ADMIN) - await other.boot() - assert other.get_by_id(created.id) is not None - - await writer.remove("carol") - assert other.get_by_id(created.id) is None - - -async def test_authenticate_returns_the_stored_role(tmp_path: Path): - """Login must hand back the row's role, not this worker's earlier copy.""" - writer, other = await _two_workers(tmp_path) - await writer.create(username="dave", password="TestPass12", role=Role.USER) - await other.boot() - await writer.set_role("dave", Role.ADMIN) - - logged_in = await other.authenticate("dave", "TestPass12") - assert logged_in is not None - assert logged_in.role is Role.ADMIN diff --git a/tests/unit/test_wiki_summary_plugin.py b/tests/unit/test_wiki_summary_plugin.py deleted file mode 100644 index 572f422eff..0000000000 --- a/tests/unit/test_wiki_summary_plugin.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Wiki-summary plugin must keep every request under ``*.wikipedia.org``.""" - -from __future__ import annotations - -import importlib.util -import json -from typing import Any - -from octop.infra.agents.plugins.bundled import default_bundled_plugins_root - - -def _load_wiki_summary() -> Any: - path = default_bundled_plugins_root() / "wiki-summary" / "main.py" - spec = importlib.util.spec_from_file_location("bundled_wiki_summary", path) - assert spec is not None and spec.loader is not None - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod - - -class _FakeResp: - def __init__(self, payload: Any, status: int = 200) -> None: - self._payload = payload - self.status_code = status - - def raise_for_status(self) -> None: - if self.status_code >= 400: - raise RuntimeError(f"http {self.status_code}") - - def json(self) -> Any: - return self._payload - - -class _RecordingClient: - urls: list[str] = [] - - def __init__(self, *args: Any, **kwargs: Any) -> None: - pass - - def __enter__(self) -> _RecordingClient: - return self - - def __exit__(self, *args: Any) -> None: - return None - - def get(self, url: str, **kwargs: Any) -> _FakeResp: - type(self).urls.append(url) - if "/api/rest_v1/page/summary/" in url: - return _FakeResp({"title": "地球", "extract": "太阳系第三行星。"}) - return _FakeResp({}) - - -def _data(payload: str) -> dict[str, Any]: - return json.loads(payload)["data"] - - -async def test_plain_lang_hits_the_wikipedia_subdomain(monkeypatch: Any) -> None: - mod = _load_wiki_summary() - _RecordingClient.urls = [] - monkeypatch.setattr(mod.httpx, "Client", _RecordingClient) - out = await mod.wiki_summary("Earth", lang="en") - assert _RecordingClient.urls == ["https://en.wikipedia.org/api/rest_v1/page/summary/Earth"] - assert _data(out)["extract"] == "太阳系第三行星。" - - -async def test_hyphenated_and_simple_lang_codes_still_resolve(monkeypatch: Any) -> None: - mod = _load_wiki_summary() - _RecordingClient.urls = [] - monkeypatch.setattr(mod.httpx, "Client", _RecordingClient) - for lang in ("zh-classical", "simple", "NB"): - await mod.wiki_summary("Earth", lang=lang) - assert [u.split("/api/")[0] for u in _RecordingClient.urls] == [ - "https://zh-classical.wikipedia.org", - "https://simple.wikipedia.org", - "https://nb.wikipedia.org", - ] - - -async def test_lang_cannot_retarget_the_host(monkeypatch: Any) -> None: - """``lang`` is interpolated into the URL host, so it must not carry URL syntax. - - The tool schema gives the model a free-form ``str``, so a value like - ``evil.com#`` would otherwise send a request to an arbitrary host and echo - its JSON body back into the chat. - """ - mod = _load_wiki_summary() - monkeypatch.setattr(mod.httpx, "Client", _RecordingClient) - for raw in ( - "evil.com#", - "localhost:8443/", - "en.wikipedia.org.evil.cn", - "x/..", - "../../etc/passwd", - "zh.wikipedia.org#x", - ): - _RecordingClient.urls = [] - out = await mod.wiki_summary("Earth", lang=raw) - assert _RecordingClient.urls == [], raw - assert _data(out).get("error") == "invalid lang", raw diff --git a/tests/unit/utils/test_ssrf_guard_pinned_request.py b/tests/unit/utils/test_ssrf_guard_pinned_request.py deleted file mode 100644 index 128fae4d2a..0000000000 --- a/tests/unit/utils/test_ssrf_guard_pinned_request.py +++ /dev/null @@ -1,76 +0,0 @@ -"""``safe_request`` rebuilds the outbound URL; the rebuild must stay valid.""" - -from __future__ import annotations - -import asyncio -import ipaddress -from typing import Any - -import httpx - -from octop.infra.utils import ssrf_guard - -# A public anycast resolver, written the way a self-hosted IPv6 IdP would be. -_IPV6_HOST = "2606:4700:4700::1111" - - -class _Captured: - def __init__(self) -> None: - self.transport_args: list[tuple[str, str]] = [] - self.urls: list[str] = [] - - -def _install(monkeypatch: Any, captured: _Captured, pin_ip: str) -> None: - async def _fake_resolve(url: str) -> str: - return pin_ip - - class _FakeTransport(httpx.AsyncBaseTransport): - def __init__(self, target_host: str, ip: str) -> None: - captured.transport_args.append((target_host, ip)) - - async def handle_async_request(self, request: httpx.Request) -> httpx.Response: - captured.urls.append(str(request.url)) - return httpx.Response(200, json={"ok": True}) - - monkeypatch.setattr(ssrf_guard, "_resolve_validated_ip", _fake_resolve) - monkeypatch.setattr(ssrf_guard, "PinnedIPTransport", _FakeTransport) - - -def test_ipv6_literal_is_a_supported_target() -> None: - """Guard the premise: validation accepts a public IPv6 literal.""" - assert not ipaddress.ip_address(_IPV6_HOST).is_private - assert ssrf_guard.validate_https_url(f"https://[{_IPV6_HOST}]/token") - - -def test_hostname_with_port_is_rebuilt_unchanged(monkeypatch: Any) -> None: - captured = _Captured() - _install(monkeypatch, captured, "203.0.113.7") - asyncio.run( - ssrf_guard.safe_request( - "POST", - "https://idp.example.com:8443/token", - data={"a": "b"}, - ), - ) - assert captured.urls == ["https://idp.example.com:8443/token"] - assert captured.transport_args == [("idp.example.com", "203.0.113.7")] - - -def test_ipv6_literal_target_keeps_its_url_brackets(monkeypatch: Any) -> None: - """``urlparse().hostname`` drops the brackets an IPv6 literal needs in a URL.""" - captured = _Captured() - _install(monkeypatch, captured, _IPV6_HOST) - asyncio.run(ssrf_guard.safe_request("POST", f"https://[{_IPV6_HOST}]/token")) - # Without the brackets httpx rejects the URL as `InvalidURL: Invalid port`. - assert captured.urls == [f"https://[{_IPV6_HOST}]/token"] - # The pin still has to match the bare address httpx hands to the backend. - assert captured.transport_args == [(_IPV6_HOST, _IPV6_HOST)] - - -def test_path_parameter_survives_the_rebuild(monkeypatch: Any) -> None: - captured = _Captured() - _install(monkeypatch, captured, "203.0.113.7") - asyncio.run( - ssrf_guard.safe_request("GET", "https://idp.example.com/mcp;session=abc?x=2"), - ) - assert captured.urls == ["https://idp.example.com/mcp;session=abc?x=2"] From 2209cdc90f948ece0dbf7b2d555691eb5776bce8 Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Fri, 25 Sep 2026 06:44:24 +0800 Subject: [PATCH 015/126] chore(deps): switch runtime packages to octop-* 1.0.0 (#1136) Replace orcakit-harness-agent / harness-* with octop-harness, octop-gateway, octop-memory, and octop-browser, and align docs, UI copy, and generated paths. Co-authored-by: jubaoliang Co-authored-by: Cursor --- AGENTS.md | 8 +- CHANGELOG.md | 17 ++ dashboard/src/api/modules/memoryDashboard.ts | 4 +- dashboard/src/components/BackendBuilder.tsx | 6 +- dashboard/src/hooks/useBrowserSessionState.ts | 2 +- dashboard/src/locales/en.json | 4 +- dashboard/src/locales/zh.json | 6 +- .../Agent/Channels/components/constants.ts | 6 +- .../src/pages/Chat/utils/expertMention.ts | 2 +- .../src/pages/Control/RemoteBrowser/index.tsx | 6 +- docker/docker-compose.postgres.yml | 2 +- docs/acp.md | 4 +- docs/adr/002-database-backends.md | 10 +- docs/agent-backend-file-io.md | 10 +- docs/agent-call-agent.md | 2 +- docs/agent-interop-mailbox.md | 14 +- docs/api.md | 8 +- docs/architecture.md | 10 +- docs/cli.md | 2 +- docs/configuration.md | 4 +- docs/discord-channel.md | 8 +- docs/expert-teams.md | 4 +- docs/memory-slim.md | 26 +-- docs/versioned-history.md | 8 +- fnos/docker/Dockerfile | 2 +- fnos/native/app/bin/octop | 2 +- fnos/native/app/bin/octop-cli | 2 +- plugins/README.md | 4 +- plugins/README_CN.md | 4 +- plugins/demo-greeting-skill/main.py | 2 +- plugins/demo-toolkit/main.py | 2 +- plugins/demo-turn-logger/main.py | 2 +- plugins/demo-ui-card/main.py | 2 +- pyproject.toml | 10 +- scripts/README.md | 2 +- scripts/install-octop.sh | 26 +-- scripts/install.bat | 4 +- scripts/install.ps1 | 6 +- scripts/install.sh | 26 +-- scripts/run-discord-local.sh | 8 +- src/octop/api/common/attachments.py | 2 +- src/octop/api/common/memory_client.py | 8 +- src/octop/api/common/workspace.py | 4 +- src/octop/api/routers/browser/__init__.py | 2 +- src/octop/api/routers/browser/env.py | 14 +- src/octop/api/routers/browser/harness.py | 24 +-- .../api/routers/browser/record_replay.py | 26 +-- src/octop/api/routers/browser/stream.py | 2 +- src/octop/api/routers/channels.py | 6 +- src/octop/api/routers/chat/history.py | 2 +- src/octop/api/routers/chat/serialize.py | 2 +- src/octop/api/routers/chat/turn.py | 2 +- src/octop/api/routers/memory.py | 4 +- src/octop/api/routers/memory_portable.py | 22 +- src/octop/api/routers/plugins.py | 2 +- src/octop/api/routers/providers.py | 2 +- src/octop/api/routers/security.py | 6 +- src/octop/api/routers/skills.py | 2 +- src/octop/api/routers/workspace.py | 4 +- src/octop/cli/commands/acp.py | 2 +- src/octop/cli/commands/models.py | 2 +- src/octop/i18n/en.json | 4 +- src/octop/i18n/zh.json | 2 +- src/octop/infra/agents/acp_settings.py | 4 +- src/octop/infra/agents/avatar.py | 2 +- .../builtin_skills/skill-manager/SKILL.md | 2 +- src/octop/infra/agents/context_breakdown.py | 4 +- src/octop/infra/agents/conversation_mode.py | 4 +- src/octop/infra/agents/experts/catalog.py | 2 +- src/octop/infra/agents/experts/publish.py | 2 +- src/octop/infra/agents/langfuse.py | 4 +- src/octop/infra/agents/manager.py | 62 +++--- src/octop/infra/agents/media_generation.py | 8 +- src/octop/infra/agents/memory_backend.py | 2 +- src/octop/infra/agents/memory_slim.py | 8 +- .../agents/middleware/workspace_image.py | 2 +- .../infra/agents/plugin_tool_defaults.py | 2 +- src/octop/infra/agents/plugin_tool_names.py | 2 +- .../plugins/bundled/air-quality/main.py | 2 +- .../plugins/bundled/bilibili-anime/main.py | 2 +- .../plugins/bundled/color-convert/main.py | 2 +- .../plugins/bundled/daily-english/main.py | 2 +- .../agents/plugins/bundled/fortune/main.py | 2 +- .../agents/plugins/bundled/fun-facts/main.py | 2 +- .../plugins/bundled/github-trending/main.py | 2 +- .../agents/plugins/bundled/hot-topics/main.py | 2 +- .../agents/plugins/bundled/ip-lookup/main.py | 2 +- .../plugins/bundled/market-quotes/main.py | 2 +- .../agents/plugins/bundled/meme-maker/main.py | 2 +- .../agents/plugins/bundled/mini-games/main.py | 2 +- .../plugins/bundled/movie-search/main.py | 2 +- .../plugins/bundled/parcel-tracker/main.py | 2 +- .../agents/plugins/bundled/pomodoro/main.py | 2 +- .../agents/plugins/bundled/qrcode/main.py | 2 +- .../plugins/bundled/server-status/main.py | 2 +- .../agents/plugins/bundled/short-link/main.py | 2 +- .../plugins/bundled/slack-calendar/main.py | 2 +- .../plugins/bundled/sports-scores/main.py | 2 +- .../agents/plugins/bundled/tetris/main.py | 2 +- .../plugins/bundled/travel-inspire/main.py | 2 +- .../plugins/bundled/unit-convert/main.py | 2 +- .../agents/plugins/bundled/weather/main.py | 2 +- .../plugins/bundled/what-to-eat/main.py | 2 +- .../plugins/bundled/wiki-summary/main.py | 2 +- src/octop/infra/agents/plugins/manager.py | 4 +- src/octop/infra/agents/profile.py | 2 +- .../infra/agents/providers/harness_factory.py | 4 +- src/octop/infra/agents/providers/presets.py | 8 +- src/octop/infra/agents/providers/probe.py | 4 +- src/octop/infra/agents/providers/store.py | 2 +- src/octop/infra/agents/runtime_limits.py | 4 +- .../infra/agents/security/policy_store.py | 2 +- .../infra/agents/security/tool_guard_rules.py | 2 +- src/octop/infra/agents/teams/team_manager.py | 10 +- src/octop/infra/backend/__init__.py | 2 +- src/octop/infra/backend/adapter.py | 2 +- src/octop/infra/backend/browse.py | 2 +- src/octop/infra/backend/probe.py | 12 +- src/octop/infra/backend/resolver.py | 2 +- src/octop/infra/backup/workspace_archive.py | 4 +- src/octop/infra/browser/setup.py | 28 +-- src/octop/infra/connectors/builder.py | 2 +- .../infra/connectors/gateway/langchain.py | 2 +- src/octop/infra/db/repos/sessions.py | 2 +- src/octop/infra/gateway/channels/qr_bind.py | 12 +- src/octop/infra/gateway/cli/cli_channel.py | 6 +- src/octop/infra/gateway/cli/turn.py | 2 +- src/octop/infra/gateway/gateway.py | 12 +- src/octop/infra/gateway/hitl/coordinator.py | 4 +- src/octop/infra/gateway/media/__init__.py | 2 +- .../infra/gateway/media/attachment_hints.py | 6 +- .../infra/gateway/media/backend_files.py | 18 +- .../infra/gateway/media/inbound_store.py | 2 +- src/octop/infra/gateway/media/ingress.py | 6 +- src/octop/infra/gateway/media/tool_media.py | 4 +- .../infra/gateway/process/agent_resolve.py | 4 +- .../infra/gateway/process/harness_request.py | 6 +- .../infra/gateway/process/message_keys.py | 10 +- src/octop/infra/gateway/process/processor.py | 16 +- .../infra/gateway/process/response_mode.py | 4 +- .../infra/gateway/process/stream_project.py | 6 +- src/octop/infra/gateway/slash/__init__.py | 2 +- src/octop/infra/gateway/slash/dispatcher.py | 2 +- .../infra/gateway/slash/handlers/composite.py | 4 +- .../infra/gateway/slash/handlers/hitl.py | 2 +- .../infra/gateway/slash/handlers/memory.py | 2 +- .../infra/gateway/slash/handlers/platform.py | 2 +- .../infra/gateway/slash/handlers/session.py | 2 +- src/octop/infra/gateway/slash/parser.py | 4 +- src/octop/infra/gateway/slash/runner.py | 2 +- .../infra/gateway/slash/runtime_bridge.py | 2 +- src/octop/infra/gateway/slash/types.py | 2 +- src/octop/infra/gateway/ws/ws_channel.py | 6 +- .../infra/history/trajectory/turn_context.py | 2 +- src/octop/infra/proactive/picker.py | 2 +- src/octop/infra/setup/self_update.py | 4 +- src/octop/infra/skills/presentation.py | 2 +- src/octop/infra/utils/browser_media.py | 24 +-- tests/fixtures/plugins/echo-tool/main.py | 2 +- tests/integration/test_browser_api.py | 12 +- .../test_browser_record_replay_api.py | 6 +- tests/integration/test_bwrap_jail.py | 20 +- tests/integration/test_memory_api.py | 14 +- .../test_postgresql_checkpoint_history.py | 2 +- tests/live/conftest.py | 2 +- tests/live/test_channel_probe.py | 6 +- tests/support/fakes.py | 14 +- tests/support/harness.py | 4 +- tests/unit/agents/test_agent_avatar.py | 2 +- tests/unit/agents/test_agent_manager.py | 38 ++-- tests/unit/agents/test_agent_registry.py | 2 +- tests/unit/agents/test_agent_tools.py | 4 +- ...clinical_learning_subscription_template.py | 2 +- tests/unit/agents/test_context_breakdown.py | 6 +- .../agents/test_expert_publish_snapshot.py | 2 +- tests/unit/agents/test_hitl_session_policy.py | 2 +- tests/unit/agents/test_mcp_tool_cache.py | 6 +- tests/unit/agents/test_memory_slim.py | 14 +- .../agents/test_provider_harness_factory.py | 2 +- tests/unit/agents/test_team_welcome.py | 2 +- .../agents/test_workspace_image_middleware.py | 2 +- tests/unit/api/test_attachments.py | 6 +- tests/unit/api/test_browser_stream_listen.py | 6 +- tests/unit/api/test_chat_history.py | 6 +- tests/unit/api/test_chat_welcome.py | 2 +- tests/unit/api/test_harness_tabs.py | 4 +- tests/unit/api/test_setup_main_agent.py | 4 +- tests/unit/api/test_terminal_sessions.py | 2 +- tests/unit/backend/test_resolver.py | 4 +- tests/unit/backup/test_workspace_archive.py | 4 +- tests/unit/browser/test_browser_setup.py | 10 +- tests/unit/gateway/test_attachment_hints.py | 6 +- .../test_attachment_path_resolution.py | 6 +- tests/unit/gateway/test_backend_files.py | 2 +- .../unit/gateway/test_chat_image_pipeline.py | 6 +- tests/unit/gateway/test_cli_channel.py | 2 +- tests/unit/gateway/test_dashboard_ws.py | 2 +- tests/unit/gateway/test_discord_channel.py | 8 +- tests/unit/gateway/test_gateway_push.py | 2 +- .../gateway/test_gateway_runtime_status.py | 2 +- .../gateway/test_global_processor_team.py | 52 ++--- tests/unit/gateway/test_harness_request.py | 2 +- tests/unit/gateway/test_hitl_channel.py | 10 +- tests/unit/gateway/test_inbound_store.py | 2 +- tests/unit/gateway/test_media_preview.py | 2 +- .../unit/gateway/test_mention_agent_calls.py | 14 +- tests/unit/gateway/test_message_build.py | 4 +- tests/unit/gateway/test_message_keys.py | 2 +- .../unit/gateway/test_preempt_stop_cancel.py | 2 +- .../test_processor_conversation_mode.py | 2 +- .../gateway/test_processor_default_open.py | 2 +- .../gateway/test_processor_resource_errors.py | 2 +- .../test_processor_trajectory_observe.py | 2 +- tests/unit/gateway/test_response_mode.py | 2 +- tests/unit/gateway/test_slash_compact.py | 2 +- .../unit/gateway/test_stream_project_tools.py | 2 +- tests/unit/gateway/test_tool_media.py | 6 +- tests/unit/gateway/test_versioned_history.py | 6 +- tests/unit/providers/test_opencode_session.py | 6 +- tests/unit/test_bundled_plugins_layout.py | 2 +- tests/unit/test_connectors.py | 2 +- tests/unit/test_host_download_allow.py | 7 + tests/unit/test_host_paths.py | 1 + tests/unit/test_langfuse_settings.py | 2 +- tests/unit/test_plugin_manager.py | 2 +- tests/unit/test_plugin_seed.py | 4 +- tests/unit/test_plugins.py | 10 +- tests/unit/test_provider_preset_expansion.py | 2 +- tests/unit/test_provider_probe.py | 2 +- tests/unit/test_security_settings.py | 2 +- tests/unit/test_storage_probe.py | 8 +- tests/unit/test_workspace_download.py | 5 + tests/unit/trajectory/test_projector.py | 4 +- tests/unit/trajectory/test_turn_context.py | 2 +- uv.lock | 202 +++++++++--------- 235 files changed, 770 insertions(+), 730 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f057414637..6cbd206eef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,8 +46,8 @@ No external queue. No required services beyond an LLM provider. | Web framework | FastAPI + uvicorn | | Async runtime | asyncio (no threads except `run_in_executor`) | | Database | SQLite via sync `sqlite3` (WAL) **or** PostgreSQL via `psycopg` / `psycopg_pool` | -| LLM runtime | `harness-agent` (LangGraph) at `/workspace/harness-agent` | -| Gateway | `harness-gateway` at `/workspace/harness-gateway` | +| LLM runtime | `octop-harness` (LangGraph) | +| Gateway | `octop-gateway` | | Frontend | React 18 + TypeScript + Vite | | Package manager | uv — always `uv run pytest`, never bare `pytest` | | API docs UI | Scalar (`scalar-fastapi`) at `/api/docs` | @@ -204,7 +204,7 @@ make build-frontend # dashboard/ → src/octop/dashboard/ **Agent scope:** Most agent routes use `/api/agents/{agent_id}/…` in the URL. A few legacy endpoints (e.g. MBTI) still take `X-Octop-Agent-Id`. Always validate ownership: load the agent row → `_assert_agent_owner(row, user)` (or admin bypass). -**Agent workspace I/O:** All reads/writes of agent workspace **content files** go through `HarnessAgent.workspace` (`BackendWorkspace` from `harness-agent`). Do **not** use `agent.backend` directly, `resolve_harness_backend`, or `Path.write_text` / `read_text` on `~/.octop/agents//` for workspace content. Do **not** branch on backend type or `virtual_mode` in Octop — path rules live in `BackendWorkspace`. +**Agent workspace I/O:** All reads/writes of agent workspace **content files** go through `HarnessAgent.workspace` (`BackendWorkspace` from `octop-harness`). Do **not** use `agent.backend` directly, `resolve_harness_backend`, or `Path.write_text` / `read_text` on `~/.octop/agents//` for workspace content. Do **not** branch on backend type or `virtual_mode` in Octop — path rules live in `BackendWorkspace`. New agents additionally keep system-scoped files under `{workspace}/.octop/` (e.g. sessions/sqlite, skills/agents system trees, auth tokens under `.octop/auth`). `system_files_path` is internal and not user-configurable; the `.octop` directory is created on first harness workspace init. Legacy agents keep auth at `{workspace}/.octop-auth`. @@ -276,7 +276,7 @@ i18n/ **API errors:** `ErrorCode` values map to `errors.` in JSON. `OctopError.to_envelope(locale=…)` and the global exception handler localize `message`. Dashboard mirrors codes under `apiErrors.*` in `dashboard/src/locales/{en,zh}.json` — tests require backend and frontend keys to match. -**IM channels:** Tool names and hint lines must be localized in `stream_project.py` — pass localized `tool_name` and pre-formatted `tool_hint_text` on `MessageEvent.tool_start/end` (harness-gateway reads `tool_hint_text` when present). Do not rely on English `ChannelConstraints.tool_hint_template` alone. +**IM channels:** Tool names and hint lines must be localized in `stream_project.py` — pass localized `tool_name` and pre-formatted `tool_hint_text` on `MessageEvent.tool_start/end` (octop-gateway reads `tool_hint_text` when present). Do not rely on English `ChannelConstraints.tool_hint_template` alone. **Frontend split** diff --git a/CHANGELOG.md b/CHANGELOG.md index 095811131c..c2e08c202d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ ## [Unreleased] +### 变更 +- 运行时依赖改为 `octop-harness[all]` / `octop-gateway` / `octop-memory` / `octop-browser` 1.0.0(原 `orcakit-harness-agent` / `harness-*`);文档、UI 文案与生成路径同步改为 `octop-*`(`~/.harness-browser` 仅作迁移/拒绝源) + +### 修复 +- 知识库文档数达到上限时不再答非所问:单库 `max_documents` 可配置之后,上传超限文档仍按字面量「at most 100」匹配错误文案,只有上限恰好是默认值 100 的库才报对,其余(如 2、500)会返回 409 `KNOWLEDGE_BASE_LIMIT`「每个用户最多可创建 20 个知识库」;现按两条报错各自的稳定措辞区分,超限一律正确返回 `KNOWLEDGE_DOC_LIMIT` +- 知识库文档重命名保留存储键的文件后缀:改名去掉 `.pdf` 等后缀后,原文下载/预览会 404、删除也会留下孤儿文件;现按 `create_text_document` 的既有做法补回原后缀,目录改名不受影响 (#1107) +- 会话列表接口 `GET /api/agents/{id}/threads` 的 `limit` 增加 1–200 边界(与消息分页沿用同一上限):此前负数会被 SQLite 解释成「不限制」,一次返回该用户的全部会话,`limit=0` 又返回空列表;越界请求现在统一拒绝 + +- 填写 IPv6 字面量地址的 MCP / OAuth 端点此前无法连通:出站请求固定 IP 时会把 URL 重建为 httpx 无法解析的形式(`InvalidURL`),同时丢弃路径参数;现按原样保留两者 +- 会话级 HITL「跳过审批」不再按进程缓存:`octop run --workers N`(或 CLI 与服务并发)时,另一个进程里撤销的跳过仍会持续自动放行工具调用,新授予的也可能不生效;改为每次判定都以 `threads.hitl_policy` 为准 +- 用户角色与禁用/删除状态改为按请求读库:多 worker(`octop run --workers N`)或 CLI 离线改写同一 SQLite 时,在另一个进程里被降级、禁用或删除的用户立即失效(#1102) +- 导入工作区 zip 时跳过 Octop 自有的 `_builtin_skills/` 条目:该前缀不允许删除或移动,归档里的同名文件会留下用户无法移除的技能 +- 工作区写入接口补上 `_builtin_skills` 保护:`PUT /workspace/file` 和 `POST /workspace/upload` 此前可以往 Octop 内置技能目录里写文件(新建目录、删除、移动、文档编辑都拒绝),写进去的文件会被当作内置技能出现在技能列表里,而删除/移动接口对该前缀一律 403,所以再也清不掉 + +- `wiki_summary` 的 `lang` 不再被拼进请求主机名:此前传 `evil.com#` 会真的向 `https://evil.com` 发请求(`localhost:8443/` 则打本机端口),并把对方返回的摘要回显进聊天;现只接受 `zh` / `en` / `zh-classical` 这类裸子域标签,其余按「语言代码无效」返回错误卡片 +- 登录验证码的 `OCTOP_CAPTCHA_V3_MIN_SCORE` 只按 `float()` 解析,未校验取值:填成 `nan` 时 `score < nan` 恒为 `False`,`recaptcha-v3` 的分数门槛被静默关闭(机器分 0.0 也能登录),负数同样放行,`inf`/大于 1 则把所有登录锁死。现按该参数已有的「不可用即回落默认值」规则处理,只接受 `0`–`1` 内的有限数值 + ## [1.0.2b2] - 2026-09-23 ### 新增 diff --git a/dashboard/src/api/modules/memoryDashboard.ts b/dashboard/src/api/modules/memoryDashboard.ts index ef93ddad73..a6154b95a8 100644 --- a/dashboard/src/api/modules/memoryDashboard.ts +++ b/dashboard/src/api/modules/memoryDashboard.ts @@ -1,9 +1,9 @@ /** - * memoryDashboard — talks to the per-agent harness-memory dashboard + * memoryDashboard — talks to the per-agent octop-memory dashboard * surface mounted at ``/api/agents/{aid}/memory/*`` (octop router). * * Each function maps one-to-one to a backend endpoint. Type defs - * mirror the wire shape the harness-memory bridge handlers emit + * mirror the wire shape the octop-memory bridge handlers emit * (see ``harness_memory.application.dashboard_data``); we deliberately * keep the shapes loose / partial and rely on the BE to never break * field names, so this client doesn't need to round-trip every CI run. diff --git a/dashboard/src/components/BackendBuilder.tsx b/dashboard/src/components/BackendBuilder.tsx index 384dd9b511..ee2202773f 100644 --- a/dashboard/src/components/BackendBuilder.tsx +++ b/dashboard/src/components/BackendBuilder.tsx @@ -1,7 +1,7 @@ /** - * BackendBuilder — visual editor for harness-agent backend specs. + * BackendBuilder — visual editor for octop-harness backend specs. * - * The harness-agent backend system supports several types + * The octop-harness backend system supports several types * (local_shell, filesystem, state, store, s3, cos, postgres) and a * special ``composite`` type that routes by path-prefix to nested * sub-backends. This component lets the user pick one of: @@ -16,7 +16,7 @@ * is uniform. * * Composite nesting is intentionally **one level only**: nested - * composites are technically legal in harness-agent but rare in + * composites are technically legal in octop-harness but rare in * practice, and an unconstrained tree editor is hard to use without * dedicated UX. If a user needs nesting they can hand-edit the JSON * preview at the bottom (a planned follow-up). diff --git a/dashboard/src/hooks/useBrowserSessionState.ts b/dashboard/src/hooks/useBrowserSessionState.ts index 3cce37a782..7dc4492b9e 100644 --- a/dashboard/src/hooks/useBrowserSessionState.ts +++ b/dashboard/src/hooks/useBrowserSessionState.ts @@ -91,7 +91,7 @@ export function useBrowserSessionState( } } } catch { - // harness-browser may be unavailable — ignore + // octop-browser may be unavailable — ignore } }, []); diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 0968f1788b..f33e563c61 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -2444,7 +2444,7 @@ "fieldMustBeJsonObject": "{{label}} must be a JSON object", "jsonMustBeObject": "Must be a JSON object", "invalidJson": "Invalid JSON", - "rawConfigTooltip": "Channel-specific config — see harness-gateway docs", + "rawConfigTooltip": "Channel-specific config — see octop-gateway docs", "getCredentials": "Get credentials", "channelSettingsNamed": "{{kind}} channel settings", "deleteConfirmTitle": "Delete channel \"{{name}}\"?", @@ -4153,6 +4153,8 @@ "uninstallSuccess": "Built-in browser removed", "uninstallFailed": "Uninstall failed", "playwrightMissing": "The playwright package is missing. Install octop[browser] extras first.", + "playwrightOptional": "The playwright package is missing (optional)", + "playwrightOptionalDesc": "octop-browser (CDP) is ready; browser features work. Install octop[browser] extras only if you need Playwright fallback.", "installLog": "Install log", "installRetry": "Retry install", "envReady": "Octop can open pages, fill forms, and take screenshots for you.", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index a8d507fa59..14fdb0fc67 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -2444,7 +2444,7 @@ "fieldMustBeJsonObject": "{{label}} 必须是 JSON 对象", "jsonMustBeObject": "必须是 JSON 对象", "invalidJson": "非法 JSON", - "rawConfigTooltip": "渠道特定配置 — 详见 harness-gateway 文档", + "rawConfigTooltip": "渠道特定配置 — 详见 octop-gateway 文档", "getCredentials": "前往获取凭据", "channelSettingsNamed": "{{kind}} 频道设置", "deleteConfirmTitle": "删除频道「{{name}}」?", @@ -2715,7 +2715,7 @@ "toolGuardYamlEditor": "规则 YAML", "toolGuardSaveRules": "保存规则", "toolGuardResetRules": "恢复默认规则", - "toolGuardResetConfirm": "恢复为 harness-agent 内置默认规则?当前修改将丢失。", + "toolGuardResetConfirm": "恢复为 octop-harness 内置默认规则?当前修改将丢失。", "toolGuardRulesSaved": "已保存 {{count}} 条规则", "toolGuardRulesSaveFailed": "保存失败,请检查 YAML 格式", "toolGuardRulesReset": "已恢复默认规则", @@ -4292,6 +4292,8 @@ "uninstallSuccess": "内置浏览器已卸载", "uninstallFailed": "卸载失败", "playwrightMissing": "playwright 包未安装,请先安装 octop[browser] extras。", + "playwrightOptional": "playwright 包未安装(可选)", + "playwrightOptionalDesc": "octop-browser (CDP) 已就绪,浏览器功能可用。如需 Playwright 备用模式,可安装 octop[browser] extras。", "installLog": "安装日志", "installRetry": "重新安装", "envReady": "可以帮你打开网页、填写表单和截图了", diff --git a/dashboard/src/pages/Agent/Channels/components/constants.ts b/dashboard/src/pages/Agent/Channels/components/constants.ts index fc8545f9e9..fbf060071e 100644 --- a/dashboard/src/pages/Agent/Channels/components/constants.ts +++ b/dashboard/src/pages/Agent/Channels/components/constants.ts @@ -32,7 +32,7 @@ export type ChannelKey = | "octopbot"; /** - * Channel kinds backed by Octop ``ChannelKind`` / harness-gateway ``BUILTIN_CHANNELS``. + * Channel kinds backed by Octop ``ChannelKind`` / octop-gateway ``BUILTIN_CHANNELS``. * ``dashboard`` / ``agentchat`` are intentionally omitted until implemented. */ export const CHANNEL_KEYS: ChannelKey[] = [ @@ -271,7 +271,7 @@ export function normalizeQqGroupContextConfig( /** * Per-kind config field schema. Drives ``ChannelDrawer``'s manual config * form. Kinds not listed here fall back to a JSON textarea so any - * harness-gateway channel still works without UI changes. + * octop-gateway channel still works without UI changes. */ export const CHANNEL_FIELDS: Partial> = { feishu: [ @@ -417,7 +417,7 @@ export const CHANNEL_DISPLAY_CONFIG_KEYS = [ "c2c_streaming", ] as const; -/** Default per-channel display settings (harness-gateway ChannelConfig). */ +/** Default per-channel display settings (octop-gateway ChannelConfig). */ export const DEFAULT_CHANNEL_DISPLAY_CONFIG = { response_mode: "stream" as const, show_thinking: false, diff --git a/dashboard/src/pages/Chat/utils/expertMention.ts b/dashboard/src/pages/Chat/utils/expertMention.ts index ca2d78bca6..09b6206e8c 100644 --- a/dashboard/src/pages/Chat/utils/expertMention.ts +++ b/dashboard/src/pages/Chat/utils/expertMention.ts @@ -1,7 +1,7 @@ /** * Composer helpers for expert / subagent @mentions. * - * harness-agent no longer intercepts ``target_agent_ids``; the host must put + * octop-harness no longer intercepts ``target_agent_ids``; the host must put * ``@Name`` in the user message so the model can call ``ask_agent`` (teammate) * or ``task`` (workspace subagent). */ diff --git a/dashboard/src/pages/Control/RemoteBrowser/index.tsx b/dashboard/src/pages/Control/RemoteBrowser/index.tsx index 2fbf342671..998e64ed4f 100644 --- a/dashboard/src/pages/Control/RemoteBrowser/index.tsx +++ b/dashboard/src/pages/Control/RemoteBrowser/index.tsx @@ -1,5 +1,5 @@ /** - * Remote Browser page — harness-browser sessions (same backend as chat). + * Remote Browser page — octop-browser sessions (same backend as chat). * * UI matches the original Playwright page: install flow, address bar, * tab bar, canvas interaction, viewport / refresh controls. @@ -1137,7 +1137,7 @@ export default function RemoteBrowserPage({ } if (!envStatus?.playwright) { - // harness-browser works without Playwright, so this is informational + // octop-browser works without Playwright, so this is informational // rather than a blocking error if (envStatus?.harness_browser) { return ( @@ -1150,7 +1150,7 @@ export default function RemoteBrowserPage({ )} description={t( "remoteBrowser.playwrightOptionalDesc", - "harness-browser (CDP) 已就绪,浏览器功能可用。如需 Playwright 备用模式,可安装 octop[browser] extras。", + "octop-browser (CDP) 已就绪,浏览器功能可用。如需 Playwright 备用模式,可安装 octop[browser] extras。", )} /> ); diff --git a/docker/docker-compose.postgres.yml b/docker/docker-compose.postgres.yml index 4f28451e7c..e2b1f242ee 100644 --- a/docker/docker-compose.postgres.yml +++ b/docker/docker-compose.postgres.yml @@ -15,7 +15,7 @@ # Init layers (ADR 002): # - Control-plane schema → Octop migrate (NNN_*.pg.sql), not this compose # - CREATE EXTENSION vector → postgres/init-vector.sql (initdb.d only) -# - Agent memory tables → harness-memory at runtime +# - Agent memory tables → octop-memory at runtime # ============================================================================= services: diff --git a/docs/acp.md b/docs/acp.md index 4fd14e325d..36cbd88c50 100644 --- a/docs/acp.md +++ b/docs/acp.md @@ -164,7 +164,7 @@ Runner shape: ## Prerequisites -- **harness-agent `[acp]` extra** — pulls in `agent-client-protocol` (included in the Octop wheel dependency on harness-agent). +- **octop-harness `[acp]` extra** — pulls in `agent-client-protocol` (included in the Octop wheel dependency on octop-harness). - **Outbound:** install external CLIs (`opencode`, `codebuddy`, …) on the machine running `octop run`. - **Inbound:** agent must start successfully (provider + model configured). @@ -190,4 +190,4 @@ Runner shape: - Dashboard: `/acp` - CLI: `octop acp --help` - OpenAPI: `/api/docs` -- Harness implementation: `harness-agent` → `harness_agent.acp` (server + `acp_runner` tool) +- Harness implementation: `octop-harness` → `octop_harness.acp` (server + `acp_runner` tool) diff --git a/docs/adr/002-database-backends.md b/docs/adr/002-database-backends.md index d7a7e662c9..75e961d1ea 100644 --- a/docs/adr/002-database-backends.md +++ b/docs/adr/002-database-backends.md @@ -9,7 +9,7 @@ ## Context -Operators need an optional PostgreSQL control plane for compliance / externalized state, while keeping zero-friction SQLite as the default. Agent memory (harness-memory) already supports a postgres backend; Octop must be able to pass it through without forking harness-*. +Operators need an optional PostgreSQL control plane for compliance / externalized state, while keeping zero-friction SQLite as the default. Agent memory (octop-memory) already supports a postgres backend; Octop must be able to pass it through without forking octop-*. ## Decision @@ -24,9 +24,9 @@ Layer 1 — Octop control plane Layer 2 — Harness checkpoints default: {workspace}/checkpoints.sqlite - when memory is postgres: reuse Memory / PostgresSaver (harness-agent) + when memory is postgres: reuse Memory / PostgresSaver (octop-harness) -Layer 3 — Agent memory (harness-memory) +Layer 3 — Agent memory (octop-memory) default on SQLite control plane: {workspace}/memory.sqlite default on PostgreSQL control plane: same DSN, schema agent_ override: config_json.memory.backend = { type: "sqlite" } @@ -54,9 +54,9 @@ Workspace content files (SOUL, skills, inbound, JSONL) remain files via `Backend | Control-plane tables / indexes | Octop migrate | `migrations/NNN_*.sql` and `NNN_*.pg.sql` | | Connection / session settings | Pool connect | SQLite: `PRAGMA` in `SqlitePool`; PG: defaults (FK on) — **not** in migrations | | Instance extensions (`vector`, …) | Ops / docker | `docker/postgres/init-vector.sql` via `initdb.d`, or DBA on managed PG | -| Agent memory DDL | harness-memory | Runtime `_init_schema` per `agent_*` schema | +| Agent memory DDL | octop-memory | Runtime `_init_schema` per `agent_*` schema | -**Hard rule:** Octop control-plane `*.pg.sql` must **not** run `CREATE EXTENSION`. Many managed Postgres roles cannot create extensions; the control plane also does not need `vector` (plain types only). harness-memory today uses built-in `tsvector`, not pgvector. Keep `CREATE EXTENSION vector` in docker/ops for optional future embedding use. +**Hard rule:** Octop control-plane `*.pg.sql` must **not** run `CREATE EXTENSION`. Many managed Postgres roles cannot create extensions; the control plane also does not need `vector` (plain types only). octop-memory today uses built-in `tsvector`, not pgvector. Keep `CREATE EXTENSION vector` in docker/ops for optional future embedding use. ### Memory wiring diff --git a/docs/agent-backend-file-io.md b/docs/agent-backend-file-io.md index cb930b2ea7..a5ee2cbc28 100644 --- a/docs/agent-backend-file-io.md +++ b/docs/agent-backend-file-io.md @@ -61,7 +61,7 @@ await backend.aupload_files([(soul_path, text.encode("utf-8"))]) `~/.octop/agents//` 仍作为 harness 的 `workspace_dir`,用于: - 构造 backend 时的 `root_dir` 挂载(默认 filesystem); -- checkpoint、sessions JSONL、harness-memory SQLite 等**本地产物**(不由 backend 协议管理); +- checkpoint、sessions JSONL、octop-memory SQLite 等**本地产物**(不由 backend 协议管理); - 终端 PTY 的 cwd(见 §6 例外)。 **内容文件**(md、skills 等)不再由 Octop 对该目录做 `read_text` / `write_text`。 @@ -91,7 +91,7 @@ await backend.aupload_files([(soul_path, text.encode("utf-8"))]) | `MEMORY.md` | 长期记忆 | | `daily/YYYY-MM-DD.md` | 日记忆;读已走 backend,删除暂可保留本地 `unlink` | | `sessions/*.jsonl` | 对话历史兜底 | -| harness-memory / checkpoint | 绑定 `workspace_dir`,非 backend 内容文件 | +| octop-memory / checkpoint | 绑定 `workspace_dir`,非 backend 内容文件 | ### 3.3 不在范围 @@ -226,7 +226,7 @@ create(agent) ## 11. 参考 - Backend 解析:`infra/agents/manager.py` → `resolve_harness_backend` -- Harness 挂载:`harness_agent.backends.resolve_backend(spec, workspace_dir=...)` +- Harness 挂载:`octop_harness.backends.resolve_backend(spec, workspace_dir=...)` - 已合规示例:`api/routers/workspace.py`、`api/routers/skills.py` - 路径布局:`infra/utils/paths.py` → `agent_workspace` / `ensure_agent_workspace` @@ -234,7 +234,7 @@ create(agent) ## 12. 局部 root_dir 与 execute jail(补充) -当 agent backend 为本地 `local_shell`,且同时满足 **Linux + `virtual_mode=True` + `root_dir` 非主机 `/` + 宿主机有 `bwrap`** 时,harness 在 **构造 backend 之前** 路由到 `BubbledLocalShellBackend`,将 `execute`(含 Skill 脚本)包进 bubblewrap:工作根绑到 `/`,与文件工具虚拟路径对齐。其余情况(宿主根、非 Linux、无 bwrap、`filesystem`)走普通 `HarnessLocalShellBackend`:无目录狱,但在 **harness-agent >= 1.0** 且 `virtual_mode` + 非宿主 `root_dir` 时,仍会把 `execute` 命令里的虚拟绝对路径改写到该 `root_dir` 下再在宿主机执行。 +当 agent backend 为本地 `local_shell`,且同时满足 **Linux + `virtual_mode=True` + `root_dir` 非主机 `/` + 宿主机有 `bwrap`** 时,harness 在 **构造 backend 之前** 路由到 `BubbledLocalShellBackend`,将 `execute`(含 Skill 脚本)包进 bubblewrap:工作根绑到 `/`,与文件工具虚拟路径对齐。其余情况(宿主根、非 Linux、无 bwrap、`filesystem`)走普通 `HarnessLocalShellBackend`:无目录狱,但在 **octop-harness >= 1.0** 且 `virtual_mode` + 非宿主 `root_dir` 时,仍会把 `execute` 命令里的虚拟绝对路径改写到该 `root_dir` 下再在宿主机执行。 `scripts/install.sh`(及 desktop Linux 安装脚本)会在 **Linux** 上尽力安装 `bubblewrap`;保存局部 `root_dir` 时仪表盘也会调用 `POST /api/filesystem/ensure-bwrap` 做同样的尽力安装。macOS / 无 bwrap 时无目录狱,文件工具仍靠 deepagents `virtual_mode`;`BackendWorkspace` 读/物化路径 failback 不变:绝对路径先 virtual 映到 `root_dir` 再原始宿主机路径;相对路径先 `{root_dir}/{rel}` 再 `{workspace_dir}/{rel}`。Dashboard path I/O: use ``dashboard/src/utils/workspaceIoPath.ts`` for download/file API paths (host absolute stays ``file://…``). Dock tab identity may still ``canonicalizeDockFilePath``; @@ -244,7 +244,7 @@ do not collapse host abs before calling BackendWorkspace. ## 13. Docker sandbox backend -需要把 agent 的文件系统工具与 `execute` 隔离到 Docker 容器时,配置 harness `type: "docker"`(需 `orcakit-harness-agent[docker]`,本机 Docker daemon 可用)。 +需要把 agent 的文件系统工具与 `execute` 隔离到 Docker 容器时,配置 harness `type: "docker"`(需 `octop-harness[docker]`,本机 Docker daemon 可用)。 宿主 ``workspace_dir`` 在**创建专家时**写入 ``config_json.workspace_dir``(默认 ``{OCTOP_HOME}/agents//``;创建时可覆盖)。**所有 backend 类型**共用这 diff --git a/docs/agent-call-agent.md b/docs/agent-call-agent.md index 0fa5398f9d..e2e5de66f9 100644 --- a/docs/agent-call-agent.md +++ b/docs/agent-call-agent.md @@ -40,7 +40,7 @@ ask_agent mode=background ## 相关文件 -- `harness_agent/teams/` — inbox、`TeamManager`、`build_team_tools` +- `octop_harness/teams/` — inbox、`TeamManager`、`build_team_tools` - `infra/gateway/processor.py` — `GlobalProcessor`(消息路由 + `TeamProcessor` 回调) - `api/routers/chat.py`、`infra/gateway/processor.py` — `@` 与 slash diff --git a/docs/agent-interop-mailbox.md b/docs/agent-interop-mailbox.md index 5dfc9db7be..f0b8ecfbab 100644 --- a/docs/agent-interop-mailbox.md +++ b/docs/agent-interop-mailbox.md @@ -1,12 +1,12 @@ # Agent Teams:Inbox + Callback 方案 **状态:** 已实现(harness `teams/` + octop `GlobalProcessor` 作为 `TeamProcessor`) -**范围:** `harness-agent`(主实现)+ `octop`(宿主适配) +**范围:** `octop-harness`(主实现)+ `octop`(宿主适配) **关联:** [agent-call-agent.md](./agent-call-agent.md)、[agent-delegation.md](./agent-delegation.md) > 说明:早前实现的 `AgentMailbox`(把所有 `stream`/`call` 透明入队)将被本方案取代。 > 本方案核心:`stream`/`call` 回到**原始直连**行为;互调能力收敛到一个**全局 inbox** + -> 可选 **callback processor**;相关代码集中到 `harness_agent/teams/`。 +> 可选 **callback processor**;相关代码集中到 `octop_harness/teams/`。 --- @@ -19,7 +19,7 @@ - **使用 inbox**:构造时提供一个 `callback`(process 方法/类)。提供后,manager 启用对 inbox 队列的处理:收到消息 → `target.call` → 拿到结果 → 组合提示词后请求 `source` agent + `source_thread_id` 再 `call` → 把返回通过 callback 暴露出来(主动推送)。 -3. `peer_agent` 工具不放在 `builtin/`。新增 `harness_agent/teams/` 目录,集中放 inbox_manager、 +3. `peer_agent` 工具不放在 `builtin/`。新增 `octop_harness/teams/` 目录,集中放 inbox_manager、 工具、callback 定义。工具**不进 agent 默认工具集**,仅在「启用 team」时由 manager 提供。 4. `ask_agent` 工具:**默认同步阻塞**等待结果返回;**仅当 manager 配置了 callback** 时才走异步 ——向 `HarnessAgentInboxManager` 投递一条消息并立即返回 `id`。 @@ -29,7 +29,7 @@ ## 2. 模块布局 ``` -harness_agent/teams/ +octop_harness/teams/ __init__.py # 导出公开符号 inbox.py # InboxMessage, InboxStatus, HarnessAgentInboxManager processor.py # TeamProcessor 协议, ReplyEvent, 默认提示词组合 @@ -37,7 +37,7 @@ harness_agent/teams/ util.py # extract_call_response / first_user_text 等纯函数 ``` -删除:`harness_agent/mailbox.py`、`harness_agent/peer.py`(内容拆入 `teams/`)。 +删除:`octop_harness/mailbox.py`、`octop_harness/peer.py`(内容拆入 `teams/`)。 --- @@ -279,7 +279,7 @@ inbox worker: ## 9. 与现有实现的差异(重构清单) -### harness-agent +### octop-harness | 文件 | 操作 | |------|------| @@ -297,7 +297,7 @@ inbox worker: |------|------| | `infra/gateway/processor.py` | `GlobalProcessor` 实现 `TeamProcessor`;`on_reply` 投递未读/IM | | `infra/agents/manager.py` | 构造 `team_processor=...`;`team_tools()` 注册;`apply_mention_agent_calls` 透传 | -| `infra/agents/agent_call.py` | 重导出从 `harness_agent.teams.util` | +| `infra/agents/agent_call.py` | 重导出从 `octop_harness.teams.util` | | `api/routers/chat.py`、`infra/gateway/processor.py` | 导入路径调整,逻辑不变 | | 相关单测 | 跟随导入调整 | diff --git a/docs/api.md b/docs/api.md index 2a28564376..658bdcb910 100644 --- a/docs/api.md +++ b/docs/api.md @@ -237,7 +237,7 @@ the server derives one from `prompt`. | Method | Path | Auth | Notes | |--------|------|------|-------| -| `GET` | `/models/presets` | user | provider templates from `harness-agent` | +| `GET` | `/models/presets` | user | provider templates from `octop-harness` | | `GET` | `/models` | user | resolved models across enabled providers | | `GET` | `/models/active` | user | `{provider_name, model}` | | `PUT` | `/models/active` | admin | body `{provider_name, model}` | @@ -391,11 +391,11 @@ registration (DCR), stores encrypted tokens in the custom MCP spec, and injects Bearer` when loading tools. Loopback and LAN MCP URLs may use HTTP and do not use remote OAuth discovery. -## Internal MCP (harness agents) +## Internal MCP (octop-harness agents) | Method | Path | Auth | Notes | |--------|------|------|-------| -| `POST`/`GET`/… | `/internal/mcp/*` | public (mTLS / network-isolated) | MCP gateway used by harness agents (not the dashboard) | +| `POST`/`GET`/… | `/internal/mcp/*` | public (mTLS / network-isolated) | MCP gateway used by octop-harness agents (not the dashboard) | ## Observability & security @@ -427,7 +427,7 @@ endpoint (public, mounted directly in `api/app.py`). |--------|------|------|-------| | `WS`/`POST`/`GET`/… | `/agents/{aid}/terminal` | owner | AI-assisted remote PTY | | `GET` | `/agents/{aid}/terminal/context` | owner | recent terminal context for the AI helper | -| `WS`/`POST`/`GET`/… | `/browser/...` | user | harness-browser sessions, live stream, record/replay | +| `WS`/`POST`/`GET`/… | `/browser/...` | user | octop-browser sessions, live stream, record/replay | | `POST` | `/browser/shutdown` | user | stop the current user's Octop-managed Chrome | | `POST` | `/agents/{aid}/upload` | user | multipart upload → `{workspace}/inbound/` | | `POST` | `/agents/{aid}/files/access-urls` | user | refresh inbound media URLs (signed) | diff --git a/docs/architecture.md b/docs/architecture.md index 802d4d665f..60031ca7cd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,8 +2,8 @@ Octop is a self-hosted AI assistant platform for multiple users and agents, delivered as a single Python process. -It glues three reusable libraries — `harness-agent` (LangGraph-based -chat runtime), `harness-gateway` (IM channel pipeline), and a React +It glues three reusable libraries — `octop-harness` (LangGraph-based +chat runtime), `octop-gateway` (IM channel pipeline), and a React + TypeScript dashboard — into one wheel that ships with a CLI, an HTTP/WebSocket API, and a web UI. @@ -23,7 +23,7 @@ HTTP/WebSocket API, and a web UI. │ Domain layer │ AgentManager Gateway GlobalProcessor │ │ │ CronManager UserManager SharedServices │ ├──────────────────────────────────────────────────────────────────┤ -│ Reusable libs │ harness-agent harness-gateway │ +│ Reusable libs │ octop-harness octop-gateway │ ├──────────────────────────────────────────────────────────────────┤ │ Storage │ SQLite or PostgreSQL control plane + file workspaces│ └──────────────────────────────────────────────────────────────────┘ @@ -112,7 +112,7 @@ sessions, audit, JWT secret) lives in either: Migrations: `src/octop/infra/db/migrations/NNN_*.sql` (SQLite) and `NNN_*.pg.sql` (PostgreSQL). Connection PRAGMAs live on the SQLite pool; PostgreSQL extensions (e.g. `vector`) are docker/ops init, not app -migrations. Agent memory DDL is owned by harness-memory. See +migrations. Agent memory DDL is owned by octop-memory. See [ADR 002](./adr/002-database-backends.md). The wheel ships the built dashboard SPA, so the entire stack is one @@ -120,7 +120,7 @@ The wheel ships the built dashboard SPA, so the entire stack is one Per-agent workspace files (Markdown, skills, expert templates) are read and written through the agent's `BackendWorkspace` -(`octop.infra.backend` → `harness_agent.backends`). The default +(`octop.infra.backend` → `octop_harness.backends`). The default backend is a `filesystem` adapter rooted at `~/.octop/agents//`; remote backends (S3, COS) are mounted on top of the same root_dir and the Octop service never reads diff --git a/docs/cli.md b/docs/cli.md index 631ce8516d..f4bd323ef6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -281,7 +281,7 @@ Usage: octop models [OPTIONS] COMMAND [ARGS]... Model catalog and active-model settings. Commands: - presets List built-in provider templates from harness-agent. + presets List built-in provider templates from octop-harness. list List all resolved models across enabled providers. active Show or set the global default model (admin). config Interactively create a provider from presets and set the active model. diff --git a/docs/configuration.md b/docs/configuration.md index 5e93fc6db2..7cccc2ce85 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -187,9 +187,9 @@ Control-plane `database` and agent memory are separate layers. Defaults: - Control plane SQLite → agent memory stays `{workspace}/memory.sqlite` (or `{workspace}/.octop/memory.sqlite` for new agents). - Control plane PostgreSQL → agent memory **defaults to the same DSN** - (harness-memory per-agent PG schema `agent_`). Runtime also needs + (octop-memory per-agent PG schema `agent_`). Runtime also needs ``langgraph-checkpoint-postgres`` (pulled in via - ``harness-memory[langgraph-postgres]``) so LangGraph checkpoints work. + ``octop-memory[langgraph-postgres]``) so LangGraph checkpoints work. To keep file memory while the control plane is PG, set on the agent: ```json diff --git a/docs/discord-channel.md b/docs/discord-channel.md index b9a1903853..f13f2da366 100644 --- a/docs/discord-channel.md +++ b/docs/discord-channel.md @@ -4,7 +4,7 @@ ## 1. 安装依赖并启动 -Discord 适配器已随 `harness-gateway 0.9.9` 发布。Octop 要求 `harness-gateway>=0.9.9`,可直接使用发布包,无需克隆相邻网关仓库: +Discord 适配器已随 `octop-gateway 0.9.9` 发布。Octop 要求 `octop-gateway>=0.9.9`,可直接使用发布包,无需克隆相邻网关仓库: ```sh cd Octop @@ -14,7 +14,7 @@ uv run octop run 可在 `uv run octop run` 后追加原有启动参数。先停止占用同一服务端口的旧进程;不要同时启动两个使用同一 Bot Token 的实例。 -仅在联调相邻 `harness-im-bridge` 源码时使用 `bash scripts/run-discord-local.sh`;该脚本会覆盖发布包为本地可编辑依赖。恢复发布包时重新运行 `uv sync --locked --extra dev`。 +仅在联调相邻 `octop-gateway` 源码时使用 `bash scripts/run-discord-local.sh`;该脚本会覆盖发布包为本地可编辑依赖。恢复发布包时重新运行 `uv sync --locked --extra dev`。 ## 2. 填写位置 @@ -54,7 +54,7 @@ uv run octop run | 检查 | 结果 | |---|---| -| harness-im-bridge `make all` | 格式、Lint、mypy 通过;458 项测试通过,13 项集成测试按默认命令排除 | +| octop-gateway `make all` | 格式、Lint、mypy 通过;458 项测试通过,13 项集成测试按默认命令排除 | | Octop `make all` | 格式、Lint、mypy 通过;3587 项测试通过,17 项条件跳过 | | Discord 最终相关后端复核 | 18 项通过,覆盖路由、实时状态、通道 CRUD 与配置探测 | | 前端通道测试 | 12 项通过,包含 Discord 入口、Token 和长数字 ID 的完整保存流程 | @@ -68,7 +68,7 @@ uv run octop run 新增「允许所有可访问频道」开关,前后端默认开启;缺少该字段的旧配置同样按开启处理。关闭并保存后保持指定频道模式;输入框保留原有 ID。私聊允许列表与直接 @触发规则不变。 -- harness-im-bridge `make all`:460 passed,13 deselected;格式、Lint、mypy 通过。 +- octop-gateway `make all`:460 passed,13 deselected;格式、Lint、mypy 通过。 - Octop `make all`:3591 passed,17 skipped;格式、Lint、mypy 通过。 - 前端通道测试:16 passed,覆盖默认开启、关闭后保存、旧配置默认值、重新编辑保留布尔值。 - 前端相关文件 ESLint 通过;TypeScript 和 Vite 构建通过,内置网页产物已更新。 diff --git a/docs/expert-teams.md b/docs/expert-teams.md index 1594ba24b4..7480a285ed 100644 --- a/docs/expert-teams.md +++ b/docs/expert-teams.md @@ -79,9 +79,9 @@ ## 运行时 -### harness-agent +### octop-harness -harness-agent 增加 `peer_invoke_mode: "sync" | "async" | "both"`(默认 `both`,保持旧行为): +octop-harness 增加 `peer_invoke_mode: "sync" | "async" | "both"`(默认 `both`,保持旧行为): - `sync`:只注入同步 `ask_agent` - `async`:只注入异步派工(发完即返回) diff --git a/docs/memory-slim.md b/docs/memory-slim.md index 24ecfdf91e..3820b3473b 100644 --- a/docs/memory-slim.md +++ b/docs/memory-slim.md @@ -2,7 +2,7 @@ ## 用户命令与表现 -升级本次 Octop 和 harness-memory 0.9.11 或更新版本,重启 Octop 一次加载控制入口。之后每次整理无需停服: +升级本次 Octop 和 octop-memory 0.9.11 或更新版本,重启 Octop 一次加载控制入口。之后每次整理无需停服: ```bash octop memory list # 只看可整理的智能体名称和 ID,不触发维护 @@ -14,7 +14,7 @@ octop memory slim --all # 顺序整理列表中所有符合条件的智能 不需要记住 agent 名称或 ID;没有默认智能体时会列出运行中、使用兼容 SQLite 记忆库的智能体, 输入编号即可。重名时可用同时显示的 ID 区分,Ctrl+C 可退出选择,不会触发整理。 已用 `octop agent use` 选择默认智能体时会沿用该选择,执行前打印目标 ID。 -本地源码也可从 harness-memory 目录执行: +本地源码也可从 octop-memory 目录执行: ```bash ./scripts/memory-slim --online # 同样支持默认智能体或编号选择 @@ -33,7 +33,7 @@ octop memory slim --all # 顺序整理列表中所有符合条件的智能 上述终端命令直接触发维护,不是预览。它连接运行中的同一个 OCTOP_HOME 实例,不会启动第二份 Octop; 不指定数据库路径,服务使用该智能体实际打开的 SQLite 文件。 -原 `harness-memory db slim FILE` / `scripts/memory-slim FILE` 仍然是离线预览入口。 +原 `octop-memory db slim FILE` / `scripts/memory-slim FILE` 仍然是离线预览入口。 终端和聊天页面显示:等待当前任务结束 → 备份 → 去重历史上下文 → 压缩 → 恢复。 等待期间不强行中断当前对话;120 秒内找不到空闲窗口时失败退出,不迁移。 @@ -57,7 +57,7 @@ JSON 进度包含 `elapsed_seconds`。`octop --json memory list` 返回可选列 PG 瘦身,只支持 SQLite;本次未执行整理,可以继续聊天。空候选提示同样说明 SQLite 范围和 PG 尚未接入,避免把“命令不支持”误解成“数据库无需维护”。中英文同步。 -底层 harness-memory 已有 PG 普通 VACUUM / VACUUM FULL 运维能力,但不是当前对话命令的后端。 +底层 octop-memory 已有 PG 普通 VACUUM / VACUUM FULL 运维能力,但不是当前对话命令的后端。 普通 VACUUM 主要供库内复用空间;FULL 重写表且阻塞读写。PG checkpoint 表在同一 database 内 可能由多个 agent/用户共享,不能仅暂停当前 agent 就调用 FULL。后续宜先做不阻塞聊天的只读诊断, 再按实际收益和共享范围提供管理员维护操作。本轮只做只读评估和提示修正,没有开启 PG 整理。 @@ -118,7 +118,7 @@ IM 暂不开放:现有 IM `user_id` 是用于会话存储的 agent owner,不 空闲预约,与既有历史回填互斥;它们已覆盖 stream/call/resume 的 admission。 同一宿主同时只整理一个库。没有自动阈值触发或周期性全量压缩。 - 仅接受正在运行、具备 `CompactSqliteSaver` 和独立连接 decoder 的 SQLite agent。 - 配套 harness-memory 必须提供 `application.checkpoint_maintenance.slim_live_checkpoints`; + 配套 octop-memory 必须提供 `application.checkpoint_maintenance.slim_live_checkpoints`; 版本不匹配、关闭记忆、PostgreSQL 或未运行的 agent 都会在改写之前失败。 - 在线库采用 WAL。迁移每批先 `BEGIN IMMEDIATE` 再读/改行,防止把并发写入覆盖回旧值。 本轮扫描固定 rowid 上界,不无限追逐新增行。内容与引用同事务提交,历史 ID、metadata、 @@ -140,7 +140,7 @@ memory/slash memory/i18n 定向回归 117 passed、2 deselected(本轮排除 strict mypy 499 files、Ruff/format 通过。这里只验证 PostgreSQL 拒绝提示,没有新增 PG 瘦身能力。 2026-09-18 确认与维护提示改造:两个仓库从各自当前 HEAD 签出 `feature/memory-slim-tool`,保留 -全部未提交改动。Octop 原分支为 `feature/harness-agent-bump`,harness-memory 原分支为 `main`。 +全部未提交改动。Octop 原分支为 `feature/octop-harness-bump`,octop-memory 原分支为 `main`。 - 上述对话/维护回归加 `tests/integration/test_memory_api.py`:201 passed,覆盖预览无任务/无改写、 确认后执行、权限和接口终态;`uv run --no-sync mypy --strict src/octop`:499 files 通过。 @@ -151,7 +151,7 @@ strict mypy 499 files、Ruff/format 通过。这里只验证 PostgreSQL 拒绝 2026-09-18 对话入口补充: ```bash -PYTHONPATH=../harness-memory/src:src uv run --no-sync pytest tests/unit/agents/test_memory_slim.py tests/unit/gateway/test_slash*.py tests/unit/gateway/test_message_keys.py tests/unit/gateway/test_gateway.py tests/unit/gateway/test_history_projection.py tests/unit/i18n -q +PYTHONPATH=../octop-memory/src:src uv run --no-sync pytest tests/unit/agents/test_memory_slim.py tests/unit/gateway/test_slash*.py tests/unit/gateway/test_message_keys.py tests/unit/gateway/test_gateway.py tests/unit/gateway/test_history_projection.py tests/unit/i18n -q ``` 178 passed,覆盖真实临时 SQLite 的对话触发、历史保留、用户隔离、批次串行/互斥、所有权变更、 @@ -166,8 +166,8 @@ PYTHONPATH=../harness-memory/src:src uv run --no-sync pytest tests/unit/agents/t 本机控制认证/进度及 CLI 中文输出。前端测试覆盖每个阻塞阶段与成功/失败后的发送恢复。 ```bash -# 从 Octop 仓库运行,加载配套 harness-memory 源码。 -PYTHONPATH=../harness-memory/src:src uv run --no-sync pytest tests/unit/agents/test_memory_slim.py tests/unit/i18n -q +# 从 Octop 仓库运行,加载配套 octop-memory 源码。 +PYTHONPATH=../octop-memory/src:src uv run --no-sync pytest tests/unit/agents/test_memory_slim.py tests/unit/i18n -q cd dashboard npm test -- src/pages/Chat/hooks/useMemoryMaintenance.test.ts npm run build @@ -186,14 +186,14 @@ strict mypy 498 files、Ruff/format、wrapper `--online --all --help` 通过。 2026-09-18 验证结果: -- harness-memory checkpoint compaction:37 passed(含长 reader 与并发写入)。 +- octop-memory checkpoint compaction:37 passed(含长 reader 与并发写入)。 - Octop 协调器/本机控制/CLI/记忆面板保护 + 原 memory API 集成 + i18n:90 passed。 本机 socket 测试因默认 sandbox 不允许 bind,放行仅此临时测试后通过;没有使用真实用户库。 - 更广的 agent manager、启动及 CLI registry 回归:153 passed、1 skipped;唯一初次失败为 上述 socket bind 权限,已在后续放行的测试中通过。 -- Ruff/format、strict mypy(Octop 498 files、harness-memory 107 files)通过; +- Ruff/format、strict mypy(Octop 498 files、octop-memory 107 files)通过; 前端 2 个阶段恢复测试、TypeScript 编译与生产构建通过。 -- 两仓完整门禁均未全绿:harness-memory 的 24 个既有测试访问不可写的默认用户目录失败; +- 两仓完整门禁均未全绿:octop-memory 的 24 个既有测试访问不可写的默认用户目录失败; Octop 全量在旧 captcha 测试绑定端口时报 PermissionError,确认环境原因后中止全量运行, 复跑该文件定位为 2 passed / 1 error。未把中止结果或 PostgreSQL skip 当成通过。 - 多 GiB 库/完整生产 IM 端到端/长期磁盘增长未测;本轮没有提交、部署或重启真实 Octop。 @@ -205,7 +205,7 @@ strict mypy 498 files、Ruff/format、wrapper `--online --all --help` 通过。 支持首次配置完成后加载 AgentManager。 - `/memory` 在 catalog 声明 `persist_checkpoint=False`;processor 统一读取保存策略, 普通命令及别名继续写入 checkpoint 和已就绪的聊天展示记录。 -- `UV_CACHE_DIR=/tmp/octop-uv-cache PYTHONPATH=../harness-memory/src:src make all RUN='uv run --no-sync' PYTEST_JOBS=4`: +- `UV_CACHE_DIR=/tmp/octop-uv-cache PYTHONPATH=../octop-memory/src:src make all RUN='uv run --no-sync' PYTEST_JOBS=4`: 3361 passed、18 skipped;Ruff/format 和 mypy(499 source files)通过。 - `cd dashboard && npm test -- src/pages/Chat/components/MemoryMaintenanceBanner.test.tsx src/pages/Chat/hooks/useMemoryMaintenance.test.ts`: 9 passed。提交钩子另执行 change-aware 检查和 `npm run build`(包含 `tsc -b`)。 diff --git a/docs/versioned-history.md b/docs/versioned-history.md index 452c930b81..f9f4756121 100644 --- a/docs/versioned-history.md +++ b/docs/versioned-history.md @@ -29,15 +29,15 @@ checkpoint、Memory 原始记录及会话 JSONL 仍遵循现有行为。 ## Checkpoint 读取兼容性 分段归档关闭时,旧投影回填仍可读取 checkpoint。SQLite 回填使用独立只读连接、事务和内容缓存; -若 harness-memory 使用共享正文格式,须同时安装提供 `CheckpointSerializer.with_connection` 的 -harness-memory 和本次 Octop 读取修正。原有 inline 格式仍可读取。 +若 octop-memory 使用共享正文格式,须同时安装提供 `CheckpointSerializer.with_connection` 的 +octop-memory 和本次 Octop 读取修正。原有 inline 格式仍可读取。 PostgreSQL 不进入 SQLite 读取入口,继续经 graph 恢复消息;这不改变下述分段归档的 SQLite-only 限制。 ## 启用 适用范围:SQLite 主库、单个 Octop 服务进程。PostgreSQL 开启此功能会明确拒绝启动。 -上线需同时包含本次 Octop 变更及 harness-agent 流式协议变更;后者提供模型消息 ID 和来源关联。 -本地仅修改源码不会自动升级虚拟环境里已安装的 harness-agent。 +上线需同时包含本次 Octop 变更及 octop-harness 流式协议变更;后者提供模型消息 ID 和来源关联。 +本地仅修改源码不会自动升级虚拟环境里已安装的 octop-harness。 1. 先停止新请求,等待运行中的任务结束。保留当前应用版本及完整数据目录备份。 2. 在测试数据副本中安装包含上述变更的兼容版本。 diff --git a/fnos/docker/Dockerfile b/fnos/docker/Dockerfile index b9cfd71682..8b9ff182bd 100644 --- a/fnos/docker/Dockerfile +++ b/fnos/docker/Dockerfile @@ -84,7 +84,7 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh # 安装项目 + desktop 附加组件(Playwright Chromium 不预装,控制台可按需下载) # UV_INDEX_URL 首选阿里云镜像加速;UV_DEFAULT_INDEX 保留 PyPI 作为兜底, -# 避免新发布的包(如 orcakit-harness-agent 0.9.16)尚未同步到镜像时构建失败。 +# 避免新发布的包(如 octop-harness 0.9.16)尚未同步到镜像时构建失败。 RUN --mount=type=cache,target=/root/.cache/uv \ uv venv /app/.venv \ && UV_DEFAULT_INDEX=https://pypi.org/simple \ diff --git a/fnos/native/app/bin/octop b/fnos/native/app/bin/octop index a80f0196f2..1f0d76c889 100644 --- a/fnos/native/app/bin/octop +++ b/fnos/native/app/bin/octop @@ -51,7 +51,7 @@ PY="$(find_python312)" || { SITE_PACKAGES="$APPDEST/site-packages" export PYTHONPATH="${SITE_PACKAGES}${PYTHONPATH:+:${PYTHONPATH}}" -# 内置在线升级优先使用 uv(pip 对 orcakit-harness-agent[all] 依赖树解析会卡死)。 +# 内置在线升级优先使用 uv(pip 对 octop-harness[all] 依赖树解析会卡死)。 # 系统无 uv 且当前为 root 时自动安装一次(装到系统 Python bin,软链到 /usr/local/bin)。 if [ "$(id -u)" = "0" ] && ! command -v uv >/dev/null 2>&1; then "$PY" -m pip install -q --disable-pip-version-check uv 2>/dev/null || true diff --git a/fnos/native/app/bin/octop-cli b/fnos/native/app/bin/octop-cli index a3b50e2929..f2b84a4b93 100755 --- a/fnos/native/app/bin/octop-cli +++ b/fnos/native/app/bin/octop-cli @@ -60,7 +60,7 @@ export HOME="$DATA_DIR" export OCTOP_HOME="$DATA_DIR/.octop" export OCTOP_DATA_DIR="$DATA_DIR" -# 内置在线升级优先使用 uv(pip 对 orcakit-harness-agent[all] 依赖树解析会卡死)。 +# 内置在线升级优先使用 uv(pip 对 octop-harness[all] 依赖树解析会卡死)。 # 系统无 uv 且当前为 root 时自动安装一次(装到系统 Python bin,软链到 /usr/local/bin)。 if [ "$(id -u)" = "0" ] && ! command -v uv >/dev/null 2>&1; then "$PY" -m pip install -q --disable-pip-version-check uv 2>/dev/null || true diff --git a/plugins/README.md b/plugins/README.md index 9eed4374b2..f2f6acce5b 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -2,7 +2,7 @@ [中文版](./README_CN.md) -Sample plugins for Octop / harness-agent `kind` values, plus a **dual +Sample plugins for Octop / octop-harness `kind` values, plus a **dual frontend + backend** demo that renders tool results in chat. Layout inspired by [octop-toolkit](https://github.com/veenyi/octop-plugins/tree/main/octop-toolkit). @@ -102,7 +102,7 @@ The ZIP must contain **exactly one** plugin root that includes `plugin.yaml`. ```bash uv run python - <<'PY' from pathlib import Path -from harness_agent.plugins import PluginRegistry, load_plugin_dir +from octop_harness.plugins import PluginRegistry, load_plugin_dir for name in ("demo-toolkit", "demo-greeting-skill", "demo-turn-logger", "demo-ui-card"): PluginRegistry.reset() diff --git a/plugins/README_CN.md b/plugins/README_CN.md index d78dc2263f..7e8a4bad1c 100644 --- a/plugins/README_CN.md +++ b/plugins/README_CN.md @@ -2,7 +2,7 @@ [English](./README.md) -本目录提供插件 demo,对应 Octop / harness-agent 支持的 `kind`,以及**前后端一体**的 UI 渲染示例。 +本目录提供插件 demo,对应 Octop / octop-harness 支持的 `kind`,以及**前后端一体**的 UI 渲染示例。 结构参考 [octop-toolkit](https://github.com/veenyi/octop-plugins/tree/main/octop-toolkit)。 随 Octop 安装包一并分发的产品插件在 `src/octop/infra/agents/plugins/bundled/`。 @@ -111,7 +111,7 @@ zip -r demo-toolkit.zip demo-toolkit/ ```bash uv run python - <<'PY' from pathlib import Path -from harness_agent.plugins import PluginRegistry, load_plugin_dir +from octop_harness.plugins import PluginRegistry, load_plugin_dir for name in ("demo-toolkit", "demo-greeting-skill", "demo-turn-logger", "demo-ui-card"): PluginRegistry.reset() diff --git a/plugins/demo-greeting-skill/main.py b/plugins/demo-greeting-skill/main.py index 2741a5feb5..ecedc3a9fb 100644 --- a/plugins/demo-greeting-skill/main.py +++ b/plugins/demo-greeting-skill/main.py @@ -7,7 +7,7 @@ from __future__ import annotations -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext def setup(ctx: PluginContext) -> None: diff --git a/plugins/demo-toolkit/main.py b/plugins/demo-toolkit/main.py index 1b36d99536..079ffb7d4f 100644 --- a/plugins/demo-toolkit/main.py +++ b/plugins/demo-toolkit/main.py @@ -13,7 +13,7 @@ from datetime import UTC, datetime -from harness_agent.plugins import PluginContext, get_tool_config +from octop_harness.plugins import PluginContext, get_tool_config async def get_current_time(tz: str = "UTC") -> str: diff --git a/plugins/demo-turn-logger/main.py b/plugins/demo-turn-logger/main.py index aa7d900ad8..cd5deb9491 100644 --- a/plugins/demo-turn-logger/main.py +++ b/plugins/demo-turn-logger/main.py @@ -13,7 +13,7 @@ import logging from typing import Any -from harness_agent.plugins import AgentMiddleware, PluginContext +from octop_harness.plugins import AgentMiddleware, PluginContext logger = logging.getLogger("octop.plugins.demo_turn_logger") diff --git a/plugins/demo-ui-card/main.py b/plugins/demo-ui-card/main.py index 0dfd17bba3..10ee223fa3 100644 --- a/plugins/demo-ui-card/main.py +++ b/plugins/demo-ui-card/main.py @@ -8,7 +8,7 @@ import json -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext async def demo_ui_card(title: str = "Hello from plugin UI", count: int = 1) -> str: diff --git a/pyproject.toml b/pyproject.toml index c1936b43cf..59ef107ac9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "octop" version = "1.0.2b2" -description = "Smarter self-hosted AI assistant for multiple users and agents, built on harness-agent" +description = "Smarter self-hosted AI assistant for multiple users and agents, built on octop-harness" readme = "README.md" license = { text = "MIT" } requires-python = ">=3.12" @@ -21,10 +21,10 @@ dependencies = [ "apscheduler>=3.10,<4", "argon2-cffi>=23.1", "pyjwt>=2.8", - "orcakit-harness-agent[all]>=1.0.14", - "harness-memory>=0.9.11", + "octop-harness[all]>=1.0.0", + "octop-memory>=1.0.0", "lark-oapi>=1.7.3", - "harness-gateway>=0.9.9", + "octop-gateway>=1.0.0", "cryptography>=41", "scalar-fastapi>=1.0", "edge-tts>=6.1", @@ -33,7 +33,7 @@ dependencies = [ "acme>=5.6.0", "josepy>=2.2.0", "boto3>=1.40.61", - "harness-browser>=0.7.9", + "octop-browser>=1.0.0", "playwright>=1.40", "psycopg[binary]>=3.2", "langgraph-checkpoint-postgres>=2.0", diff --git a/scripts/README.md b/scripts/README.md index 729978137f..06e51c11ec 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -81,7 +81,7 @@ powershell -File scripts/wheel_build.ps1 输出:`dist/*.whl`、`dist/*.tar.gz` -发布前请确认 `pyproject.toml` 中的 `orcakit-harness-agent`、`harness-gateway` 等依赖已发布到 PyPI(`[tool.uv.sources]` 仅对 uv 本地开发生效,pip/PyPI 不读取)。 +发布前请确认 `pyproject.toml` 中的 `octop-harness`、`octop-gateway` 等依赖已发布到 PyPI(`[tool.uv.sources]` 仅对 uv 本地开发生效,pip/PyPI 不读取)。 --- diff --git a/scripts/install-octop.sh b/scripts/install-octop.sh index abeef49060..38f011183a 100644 --- a/scripts/install-octop.sh +++ b/scripts/install-octop.sh @@ -17,9 +17,9 @@ OCTOP_BIN="$OCTOP_HOME/bin" PYTHON_VERSION="3.12" OCTOP_REPO="${OCTOP_REPO:-https://github.com/TencentCloud/Octop.git}" _OCTOP_REPO_BASE="${OCTOP_REPO%/*}" -HARNESS_AGENT_REPO="${HARNESS_AGENT_REPO:-${_OCTOP_REPO_BASE}/harness-agent.git}" -HARNESS_GATEWAY_REPO="${HARNESS_GATEWAY_REPO:-${_OCTOP_REPO_BASE}/harness-gateway.git}" -HARNESS_BROWSER_REPO="${HARNESS_BROWSER_REPO:-${_OCTOP_REPO_BASE}/harness-browser.git}" +HARNESS_AGENT_REPO="${HARNESS_AGENT_REPO:-${_OCTOP_REPO_BASE}/octop-harness.git}" +HARNESS_GATEWAY_REPO="${HARNESS_GATEWAY_REPO:-${_OCTOP_REPO_BASE}/octop-gateway.git}" +HARNESS_BROWSER_REPO="${HARNESS_BROWSER_REPO:-${_OCTOP_REPO_BASE}/octop-browser.git}" if [ -n "${BASH_SOURCE[0]:-}" ]; then _SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -97,9 +97,9 @@ Environment variables: OCTOP_HOME Install directory (default: ~/.octop) OCTOP_PYPI_MIRROR PyPI mirror URL (same as --mirror) OCTOP_REPO Git URL to clone (used by --from-source with no local dir) - HARNESS_AGENT_REPO harness-agent repo (derived from OCTOP_REPO by default) - HARNESS_GATEWAY_REPO harness-gateway repo (derived from OCTOP_REPO by default) - HARNESS_BROWSER_REPO harness-browser repo (used for source installs) + HARNESS_AGENT_REPO octop-harness repo (derived from OCTOP_REPO by default) + HARNESS_GATEWAY_REPO octop-gateway repo (derived from OCTOP_REPO by default) + HARNESS_BROWSER_REPO octop-browser repo (used for source installs) PLAYWRIGHT_DOWNLOAD_HOST Playwright download mirror (optional; auto: npmmirror -> official) PLAYWRIGHT_INSTALL_TIMEOUT Per-mirror download timeout in seconds (default 600) @@ -621,7 +621,7 @@ prepare_console() { } # TEMP: mcp 2.x 移除 RequestContext,与 langchain-mcp-adapters 不兼容。 -# harness-agent>=0.9.18 已在依赖中 pin;此处在验证前再钉一次,覆盖仍拉取到 +# octop-harness>=0.9.18 已在依赖中 pin;此处在验证前再钉一次,覆盖仍拉取到 # 旧版 harness / 镜像滞后的安装路径。待 Octop 发版跟上后可删除。 _pin_mcp_compat() { info "Pinning mcp<2 (langchain-mcp-adapters compatibility; temporary)..." @@ -644,10 +644,10 @@ _clone_source_workspace() { local workdir="$1" command -v git &>/dev/null || die "git is required to clone the repos. Install git or use --from-pypi." mkdir -p "$workdir" - info "Cloning harness-agent / harness-gateway / Octop sources..." - git clone --depth 1 "$HARNESS_AGENT_REPO" "$workdir/harness-agent" - git clone --depth 1 "$HARNESS_GATEWAY_REPO" "$workdir/harness-gateway" - git clone --depth 1 "$HARNESS_BROWSER_REPO" "$workdir/harness-browser" + info "Cloning octop-harness / octop-gateway / Octop sources..." + git clone --depth 1 "$HARNESS_AGENT_REPO" "$workdir/octop-harness" + git clone --depth 1 "$HARNESS_GATEWAY_REPO" "$workdir/octop-gateway" + git clone --depth 1 "$HARNESS_BROWSER_REPO" "$workdir/octop-browser" git clone --depth 1 "$OCTOP_REPO" "$workdir/orca" } @@ -838,12 +838,12 @@ _install_playwright_browsers() { # 检测系统是否已安装 Chrome / Chromium。 # GUI 系统(macOS / Windows / Linux 桌面)通常已自带,无需再下载 Playwright 自带 Chromium。 _detect_system_chrome() { - # 优先复用 harness-browser 的探测器(与运行期 launch 路径一致) + # 优先复用 octop-browser 的探测器(与运行期 launch 路径一致) local chrome chrome="$("$OCTOP_VENV/bin/python" -c ' import sys try: - from harness_browser.cdp.launcher import find_chrome + from octop_browser.cdp.launcher import find_chrome except Exception: sys.exit(0) p = find_chrome() diff --git a/scripts/install.bat b/scripts/install.bat index 8c500688da..6b25151d8f 100644 --- a/scripts/install.bat +++ b/scripts/install.bat @@ -168,8 +168,8 @@ exit /b 0 ::detect_chrome set "OCTOP_SYSTEM_CHROME=" -REM Prefer harness-browser's own detector (same path used at runtime) -"%VENV_PYTHON%" -c "from harness_browser.cdp.launcher import find_chrome; p=find_chrome(); print(p or '', end='')" > "%TEMP%\_octop_chrome.tmp" 2>nul +REM Prefer octop-browser's own detector (same path used at runtime) +"%VENV_PYTHON%" -c "from octop_browser.cdp.launcher import find_chrome; p=find_chrome(); print(p or '', end='')" > "%TEMP%\_octop_chrome.tmp" 2>nul if not errorlevel 1 ( set /p _CHROME=<"%TEMP%\_octop_chrome.tmp" ) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 48c4cb1c9d..341b26303f 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -153,7 +153,7 @@ if (-not (Test-Path $VenvPython)) { Stop-WithError "Failed to create virtual env $pyVersion = & $VenvPython --version 2>&1 Write-Info "Python environment ready ($pyVersion)" -# TEMP: mcp 2.x breaks langchain-mcp-adapters; pin until published octop pulls harness-agent>=0.9.18. +# TEMP: mcp 2.x breaks langchain-mcp-adapters; pin until published octop pulls octop-harness>=0.9.18. function Pin-McpCompat { Write-Info "Pinning mcp<2 (langchain-mcp-adapters compat; temporary)..." uv pip install "mcp>=1.27.1,<2" --python $VenvPython --quiet @@ -255,9 +255,9 @@ if (-not $script:ConsoleAvailable) { } function Find-SystemChrome { - # Prefer harness-browser's detector (same path used at runtime) + # Prefer octop-browser's detector (same path used at runtime) try { - $p = & $VenvPython -c "from harness_browser.cdp.launcher import find_chrome; p=find_chrome(); print(p or '', end='')" 2>$null + $p = & $VenvPython -c "from octop_browser.cdp.launcher import find_chrome; p=find_chrome(); print(p or '', end='')" 2>$null if ($p) { return $p } } catch { } # Common commands diff --git a/scripts/install.sh b/scripts/install.sh index e98e52490b..58214caca5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -17,9 +17,9 @@ OCTOP_BIN="$OCTOP_HOME/bin" PYTHON_VERSION="3.12" OCTOP_REPO="${OCTOP_REPO:-https://github.com/TencentCloud/Octop.git}" _OCTOP_REPO_BASE="${OCTOP_REPO%/*}" -HARNESS_AGENT_REPO="${HARNESS_AGENT_REPO:-${_OCTOP_REPO_BASE}/harness-agent.git}" -HARNESS_GATEWAY_REPO="${HARNESS_GATEWAY_REPO:-${_OCTOP_REPO_BASE}/harness-gateway.git}" -HARNESS_BROWSER_REPO="${HARNESS_BROWSER_REPO:-${_OCTOP_REPO_BASE}/harness-browser.git}" +HARNESS_AGENT_REPO="${HARNESS_AGENT_REPO:-${_OCTOP_REPO_BASE}/octop-harness.git}" +HARNESS_GATEWAY_REPO="${HARNESS_GATEWAY_REPO:-${_OCTOP_REPO_BASE}/octop-gateway.git}" +HARNESS_BROWSER_REPO="${HARNESS_BROWSER_REPO:-${_OCTOP_REPO_BASE}/octop-browser.git}" if [ -n "${BASH_SOURCE[0]:-}" ]; then _SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -97,9 +97,9 @@ Environment variables: OCTOP_HOME Install directory (default: ~/.octop) OCTOP_PYPI_MIRROR PyPI mirror URL (same as --mirror) OCTOP_REPO Git URL to clone (used by --from-source with no local dir) - HARNESS_AGENT_REPO harness-agent repo (derived from OCTOP_REPO by default) - HARNESS_GATEWAY_REPO harness-gateway repo (derived from OCTOP_REPO by default) - HARNESS_BROWSER_REPO harness-browser repo (used for source installs) + HARNESS_AGENT_REPO octop-harness repo (derived from OCTOP_REPO by default) + HARNESS_GATEWAY_REPO octop-gateway repo (derived from OCTOP_REPO by default) + HARNESS_BROWSER_REPO octop-browser repo (used for source installs) PLAYWRIGHT_DOWNLOAD_HOST Playwright download mirror (optional; auto: npmmirror -> official) PLAYWRIGHT_INSTALL_TIMEOUT Per-mirror download timeout in seconds (default 600) @@ -621,7 +621,7 @@ prepare_console() { } # TEMP: mcp 2.x 移除 RequestContext,与 langchain-mcp-adapters 不兼容。 -# harness-agent>=0.9.18 已在依赖中 pin;此处在验证前再钉一次,覆盖仍拉取到 +# octop-harness>=0.9.18 已在依赖中 pin;此处在验证前再钉一次,覆盖仍拉取到 # 旧版 harness / 镜像滞后的安装路径。待 Octop 发版跟上后可删除。 _pin_mcp_compat() { info "Pinning mcp<2 (langchain-mcp-adapters compatibility; temporary)..." @@ -644,10 +644,10 @@ _clone_source_workspace() { local workdir="$1" command -v git &>/dev/null || die "git is required to clone the repos. Install git or use --from-pypi." mkdir -p "$workdir" - info "Cloning harness-agent / harness-gateway / Octop sources..." - git clone --depth 1 "$HARNESS_AGENT_REPO" "$workdir/harness-agent" - git clone --depth 1 "$HARNESS_GATEWAY_REPO" "$workdir/harness-gateway" - git clone --depth 1 "$HARNESS_BROWSER_REPO" "$workdir/harness-browser" + info "Cloning octop-harness / octop-gateway / Octop sources..." + git clone --depth 1 "$HARNESS_AGENT_REPO" "$workdir/octop-harness" + git clone --depth 1 "$HARNESS_GATEWAY_REPO" "$workdir/octop-gateway" + git clone --depth 1 "$HARNESS_BROWSER_REPO" "$workdir/octop-browser" git clone --depth 1 "$OCTOP_REPO" "$workdir/orca" } @@ -873,12 +873,12 @@ _install_playwright_browsers() { # 检测系统是否已安装 Chrome / Chromium。 # GUI 系统(macOS / Windows / Linux 桌面)通常已自带,无需再下载 Playwright 自带 Chromium。 _detect_system_chrome() { - # 优先复用 harness-browser 的探测器(与运行期 launch 路径一致) + # 优先复用 octop-browser 的探测器(与运行期 launch 路径一致) local chrome chrome="$("$OCTOP_VENV/bin/python" -c ' import sys try: - from harness_browser.cdp.launcher import find_chrome + from octop_browser.cdp.launcher import find_chrome except Exception: sys.exit(0) p = find_chrome() diff --git a/scripts/run-discord-local.sh b/scripts/run-discord-local.sh index e85ee6a57c..f4d5190c3a 100644 --- a/scripts/run-discord-local.sh +++ b/scripts/run-discord-local.sh @@ -2,7 +2,7 @@ # Run Octop against the sibling gateway source for local development. set -euo pipefail cd "$(dirname "$0")/.." -bridge_dir="$(cd ../harness-im-bridge && pwd)" +bridge_dir="$(cd ../octop-gateway && pwd)" if [[ ! -x .venv/bin/python ]]; then echo "Create the Octop development environment first: uv sync --extra dev" >&2 exit 1 @@ -12,9 +12,9 @@ import sys from pathlib import Path try: - import harness_gateway - expected = Path(sys.argv[1]) / "src/harness_gateway/__init__.py" - sys.exit(Path(harness_gateway.__file__).resolve() != expected.resolve()) + import octop_gateway + expected = Path(sys.argv[1]) / "src/octop_gateway/__init__.py" + sys.exit(Path(octop_gateway.__file__).resolve() != expected.resolve()) except ImportError: sys.exit(1) PY diff --git a/src/octop/api/common/attachments.py b/src/octop/api/common/attachments.py index be8a3a1f99..87fbdb6ca9 100644 --- a/src/octop/api/common/attachments.py +++ b/src/octop/api/common/attachments.py @@ -10,7 +10,7 @@ from octop.infra.gateway.media.inbound_store import InboundFile, write_inbound if TYPE_CHECKING: - from harness_agent.backends.workspace import BackendWorkspace + from octop_harness.backends.workspace import BackendWorkspace @dataclass(frozen=True) diff --git a/src/octop/api/common/memory_client.py b/src/octop/api/common/memory_client.py index a73565c970..119f308ade 100644 --- a/src/octop/api/common/memory_client.py +++ b/src/octop/api/common/memory_client.py @@ -1,6 +1,6 @@ """Per-agent ``Memory`` instance management for the dashboard router. -Owns a tiny LRU cache of ``harness_memory.core.Memory`` instances +Owns a tiny LRU cache of ``octop_memory.core.Memory`` instances keyed by ``agent_id``. Backend may be sqlite (default workspace file) or postgres when ``config_json.memory.backend`` says so. """ @@ -24,7 +24,7 @@ def memory_namespace(agent_id: str) -> str: - """Return the memory namespace harness-memory uses for ``agent_id``.""" + """Return the memory namespace octop-memory uses for ``agent_id``.""" return f"{_MEMORY_NS_PREFIX}{agent_id}" @@ -70,8 +70,8 @@ def get_or_open( backend_config: dict[str, Any] | None, fingerprint: str, ) -> tuple[Any, Any]: - from harness_memory.adapters.bridge.handlers import Bridge # noqa: PLC0415 - from harness_memory.core import Memory # noqa: PLC0415 + from octop_memory.adapters.bridge.handlers import Bridge # noqa: PLC0415 + from octop_memory.core import Memory # noqa: PLC0415 with self._lock: cached = self._entries.get(agent_id) diff --git a/src/octop/api/common/workspace.py b/src/octop/api/common/workspace.py index fd3d230946..a4fea72b8b 100644 --- a/src/octop/api/common/workspace.py +++ b/src/octop/api/common/workspace.py @@ -4,13 +4,13 @@ from typing import TYPE_CHECKING, Any, cast -from harness_agent.backends.workspace import BackendWorkspace +from octop_harness.backends.workspace import BackendWorkspace from octop.api.common.agent import require_agent_owner_row, require_agent_row from octop.infra.errors import ErrorCode, OctopError if TYPE_CHECKING: - from harness_agent import HarnessAgent + from octop_harness import HarnessAgent # deepagents.backends.utils.EMPTY_CONTENT_WARNING — shown to LLM tools, not humans. _DEEPAGENTS_EMPTY_WARNING = "System reminder: File exists but has empty contents" diff --git a/src/octop/api/routers/browser/__init__.py b/src/octop/api/routers/browser/__init__.py index 61fa8c4b5b..8e52eb2882 100644 --- a/src/octop/api/routers/browser/__init__.py +++ b/src/octop/api/routers/browser/__init__.py @@ -1,4 +1,4 @@ -"""Browser routers — env/install, harness-browser, record/replay, WS screencast.""" +"""Browser routers — env/install, octop-browser, record/replay, WS screencast.""" from __future__ import annotations diff --git a/src/octop/api/routers/browser/env.py b/src/octop/api/routers/browser/env.py index 62dc02f44e..325596bd79 100644 --- a/src/octop/api/routers/browser/env.py +++ b/src/octop/api/routers/browser/env.py @@ -1,6 +1,6 @@ """Browser environment probe and Chromium install (SSE). -Live sessions are harness-browser (see ``harness.py`` / ``stream.py``), not +Live sessions are octop-browser (see ``harness.py`` / ``stream.py``), not in-process Playwright. GET /api/browser/env-status → { playwright, browsers_ok, harness_browser, … } @@ -52,8 +52,8 @@ def _probe_env() -> dict[str, Any]: out["playwright_chromium"] = playwright_chromium_installed() try: - from harness_browser import BrowserSession # noqa: F401, PLC0415 - from harness_browser.cdp.launcher import find_chrome # noqa: PLC0415 + from octop_browser import BrowserSession # noqa: F401, PLC0415 + from octop_browser.cdp.launcher import find_chrome # noqa: PLC0415 out["harness_browser"] = True chrome = find_chrome() @@ -76,7 +76,7 @@ def _probe_env() -> dict[str, Any]: except ImportError as exc: if not out["harness_browser"]: out["error"] = ( - f"playwright not installed: {exc}. Install octop[browser] extras or harness-browser." + f"playwright not installed: {exc}. Install octop[browser] extras or octop-browser." ) return out @@ -121,7 +121,7 @@ def _verify_browser_binary(exe: str) -> tuple[bool, str]: async def install(_: Any = Depends(require_permission("browser"))) -> StreamingResponse: """Stream Chromium install progress as SSE. - Each event is a JSON line conforming to ``harness_browser.InstallEvent``: + Each event is a JSON line conforming to ``octop_browser.InstallEvent``: ``{"log": "..."}`` — progress line ``{"done": true, "success": true}`` — finished OK ``{"done": true, "success": false, "error": "..."}`` — failed @@ -136,7 +136,7 @@ async def install(_: Any = Depends(require_permission("browser"))) -> StreamingR async def _event_stream() -> AsyncGenerator[str, None]: try: - from harness_browser.cdp.launcher import find_chrome # noqa: PLC0415 + from octop_browser.cdp.launcher import find_chrome # noqa: PLC0415 chrome = find_chrome() if chrome: @@ -160,7 +160,7 @@ async def _event_stream() -> AsyncGenerator[str, None]: + "\n\n" ) - from harness_browser import install_chromium_stream # noqa: PLC0415 + from octop_browser import install_chromium_stream # noqa: PLC0415 async for event in install_chromium_stream(): yield f"data: {json.dumps(event)}\n\n" diff --git a/src/octop/api/routers/browser/harness.py b/src/octop/api/routers/browser/harness.py index a992fbc513..bdf163bfd3 100644 --- a/src/octop/api/routers/browser/harness.py +++ b/src/octop/api/routers/browser/harness.py @@ -1,4 +1,4 @@ -"""Helpers for attaching dashboard / chat UI to harness-browser sessions.""" +"""Helpers for attaching dashboard / chat UI to octop-browser sessions.""" from __future__ import annotations @@ -69,7 +69,7 @@ async def resolve_harness_session( agent_id: str | None = None, create: bool = True, ) -> Any | None: - """Return a live :class:`harness_browser.BrowserSession` for ``profile_hint``. + """Return a live :class:`octop_browser.BrowserSession` for ``profile_hint``. *profile_hint* must be a concrete profile name (``user-``). Empty / ``auto`` never falls back to another user's session or to ``default``. @@ -85,12 +85,12 @@ async def resolve_harness_session( When ``create=False`` and no live session exists, returns ``None``. """ try: - from harness_browser import BrowserSession - from harness_browser.tool_interface import _registry + from octop_browser import BrowserSession + from octop_browser.tool_interface import _registry except ImportError as exc: raise OctopError( ErrorCode.INTERNAL_ERROR, - "harness-browser not installed", + "octop-browser not installed", status=503, ) from exc @@ -145,8 +145,8 @@ async def resolve_harness_session( # Fresh ProfileManager picks up any BROWSER_USE_PROFILES_DIR relocation # done by prepare (default singleton is bound at import time). - from harness_browser.profile import ProfileManager # noqa: PLC0415 - from harness_browser.settings import settings as hb_settings # noqa: PLC0415 + from octop_browser.profile import ProfileManager # noqa: PLC0415 + from octop_browser.settings import settings as hb_settings # noqa: PLC0415 profile_manager = ProfileManager(base_dir=Path(hb_settings.profiles_dir)) @@ -259,7 +259,7 @@ async def harness_list_tabs(sess: Any) -> list[dict[str, Any]]: async def harness_sessions_payload(profile_name: str) -> dict[str, Any]: """Shape expected by the dashboard ``BrowserSessionsResponse`` type.""" try: - from harness_browser.tool_interface import _registry + from octop_browser.tool_interface import _registry except ImportError: return {"ok": False, "environment": "headless-server", "sessions": []} @@ -291,7 +291,7 @@ async def harness_sessions_payload(profile_name: str) -> dict[str, Any]: @router.get("/browser/harness-sessions") async def list_harness_sessions(user: Any = Depends(current_user)) -> dict[str, Any]: - """List the current user's live harness-browser profile.""" + """List the current user's live octop-browser profile.""" return await harness_sessions_payload(user_browser_profile(user.id)) @@ -341,16 +341,16 @@ async def handoff( @router.post( "/browser/shutdown", - summary="Stop the local Chrome process for a harness-browser profile", + summary="Stop the local Chrome process for an octop-browser profile", ) async def shutdown_browser(user: Any = Depends(current_user)) -> dict[str, Any]: """Terminate the current user's Octop-managed Chrome. Cookies stay on disk.""" try: - from harness_browser.tool_interface import browser_tool + from octop_browser.tool_interface import browser_tool except ImportError as exc: raise OctopError( ErrorCode.INTERNAL_ERROR, - "harness-browser not installed", + "octop-browser not installed", status=503, ) from exc diff --git a/src/octop/api/routers/browser/record_replay.py b/src/octop/api/routers/browser/record_replay.py index e906e5a9b4..82c22144d6 100644 --- a/src/octop/api/routers/browser/record_replay.py +++ b/src/octop/api/routers/browser/record_replay.py @@ -1,4 +1,4 @@ -"""Browser record/replay endpoints backed by harness-browser.""" +"""Browser record/replay endpoints backed by octop-browser.""" from __future__ import annotations @@ -46,11 +46,11 @@ class SkillContentBody(BaseModel): async def ensure_record_daemon() -> dict[str, Any]: try: - from harness_browser.record.daemon import ensure_daemon + from octop_browser.record.daemon import ensure_daemon except ImportError as exc: raise OctopError( ErrorCode.INTERNAL_ERROR, - "harness-browser record/replay is not installed", + "octop-browser record/replay is not installed", status=503, ) from exc return await ensure_daemon() @@ -58,11 +58,11 @@ async def ensure_record_daemon() -> dict[str, Any]: async def send_record_request(request: dict[str, Any]) -> dict[str, Any]: try: - from harness_browser.record.daemon import send_request + from octop_browser.record.daemon import send_request except ImportError as exc: raise OctopError( ErrorCode.INTERNAL_ERROR, - "harness-browser record/replay is not installed", + "octop-browser record/replay is not installed", status=503, ) from exc return await send_request(request) @@ -75,11 +75,11 @@ async def run_replay_recording( inputs: dict[str, str], ) -> dict[str, Any]: try: - from harness_browser.record.replay import ReplayRunner + from octop_browser.record.replay import ReplayRunner except ImportError as exc: raise OctopError( ErrorCode.INTERNAL_ERROR, - "harness-browser record/replay is not installed", + "octop-browser record/replay is not installed", status=503, ) from exc return await ReplayRunner().run(recording_id, profile=profile, inputs=inputs) @@ -87,7 +87,7 @@ async def run_replay_recording( def _latest_recording_id(profile: str) -> str | None: try: - from harness_browser.record.store import RecordingStore + from octop_browser.record.store import RecordingStore except ImportError: return None recordings = [ @@ -101,11 +101,11 @@ def _latest_recording_id(profile: str) -> str | None: def _require_owned_recording(recording_id: str, profile: str) -> Any: try: - from harness_browser.record.store import RecordingStore + from octop_browser.record.store import RecordingStore except ImportError as exc: raise OctopError( ErrorCode.INTERNAL_ERROR, - "harness-browser record/replay is not installed", + "octop-browser record/replay is not installed", status=503, ) from exc try: @@ -250,7 +250,7 @@ async def record_stop_and_generate_skill( skill_content = None skill_name = None try: - from harness_browser.record.store import RecordingStore + from octop_browser.record.store import RecordingStore store = RecordingStore() # Find the recording directory @@ -265,7 +265,7 @@ async def record_stop_and_generate_skill( # Derive a skill name from the recording name or ID skill_name = manifest.target.title.strip() or recording_id except ImportError: - # harness-browser not installed — skill generation not possible + # octop-browser not installed — skill generation not possible pass except Exception: # Non-critical: if we can't read the skill file, just return without it @@ -290,7 +290,7 @@ async def get_skill_content( skill_exists = False profile = user_browser_profile(user.id) manifest = _require_owned_recording(body.recording_id, profile) - from harness_browser.record.store import RecordingStore + from octop_browser.record.store import RecordingStore store = RecordingStore() rec_dir = store.recording_dir(body.recording_id) diff --git a/src/octop/api/routers/browser/stream.py b/src/octop/api/routers/browser/stream.py index e505b47006..808fd62c73 100644 --- a/src/octop/api/routers/browser/stream.py +++ b/src/octop/api/routers/browser/stream.py @@ -1,4 +1,4 @@ -"""WebSocket browser screencast — attaches to harness-browser sessions. +"""WebSocket browser screencast — attaches to octop-browser sessions. Wire protocol matches the dashboard ``useBrowserStream`` hook (screencast) and ``useBrowserSessionState`` (listen-only status): diff --git a/src/octop/api/routers/channels.py b/src/octop/api/routers/channels.py index 93de511b6d..29979033e8 100644 --- a/src/octop/api/routers/channels.py +++ b/src/octop/api/routers/channels.py @@ -685,15 +685,15 @@ async def qq_qrcode_poll( def _get_weixin_qr_login() -> Any: - """Import WeixinQRLogin lazily from harness-gateway weixin channel.""" + """Import WeixinQRLogin lazily from octop-gateway weixin channel.""" try: - from harness_gateway.channels.weixin.login_qr import WeixinQRLogin + from octop_gateway.channels.weixin.login_qr import WeixinQRLogin return WeixinQRLogin except ImportError: raise HTTPException( status_code=501, - detail="WeChat QR login requires harness-gateway with weixin channel support.", + detail="WeChat QR login requires octop-gateway with weixin channel support.", ) from None diff --git a/src/octop/api/routers/chat/history.py b/src/octop/api/routers/chat/history.py index 6fee8bd418..5462477adb 100644 --- a/src/octop/api/routers/chat/history.py +++ b/src/octop/api/routers/chat/history.py @@ -249,7 +249,7 @@ async def get_thread_context_usage( user: Any = Depends(current_user), server: Any = Depends(get_server), ) -> dict[str, Any]: - """Return persisted context-window usage for a thread (harness-agent snapshot).""" + """Return persisted context-window usage for a thread (octop-harness snapshot).""" _require_thread(server, agent_id, thread_id, user, as_user) registry = server.app_runtime.agent_registry effective_max = registry.resolve_context_max_tokens(agent_id, fallback=max_tokens) diff --git a/src/octop/api/routers/chat/serialize.py b/src/octop/api/routers/chat/serialize.py index ab5b9d5d57..6a17edcd6e 100644 --- a/src/octop/api/routers/chat/serialize.py +++ b/src/octop/api/routers/chat/serialize.py @@ -37,7 +37,7 @@ # Matches the lightweight placeholder that ``MediaOffloadMiddleware`` writes # into LangGraph state for already-offloaded inline images / audio. Format -# (see harness_agent.middleware.media_offload._placeholder_text_block): +# (see octop_harness.middleware.media_offload._placeholder_text_block): # [ offloaded: sha= path= size=B mime=; # use read_file to retrieve bytes] # We strip these on history serialization because the original bytes are diff --git a/src/octop/api/routers/chat/turn.py b/src/octop/api/routers/chat/turn.py index 6cdde88198..d4de095f42 100644 --- a/src/octop/api/routers/chat/turn.py +++ b/src/octop/api/routers/chat/turn.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any -from harness_gateway.models import ( +from octop_gateway.models import ( ChannelSubject, ContentPart, FileContent, diff --git a/src/octop/api/routers/memory.py b/src/octop/api/routers/memory.py index fc35996767..f779d448b8 100644 --- a/src/octop/api/routers/memory.py +++ b/src/octop/api/routers/memory.py @@ -2,7 +2,7 @@ Mounts at ``/api/agents/{agent_id}/memory/*`` and forwards each endpoint to a single JSON-RPC method on the agent's -``harness_memory.Bridge``. The router itself contains no business +``octop_memory.Bridge``. The router itself contains no business logic — every handler is one ``call_memory_rpc(...)`` call. Surface (mirrors the design doc §6.2): @@ -50,7 +50,7 @@ router = APIRouter() -# Defaults mirror ``HarnessAgentConfig`` (harness_agent.config). Kept in sync +# Defaults mirror ``HarnessAgentConfig`` (octop_harness.config). Kept in sync # manually — the dashboard writes these into the agent's ``config_json`` under # the ``memory`` section, and ``AgentManager._build_harness_config`` reads them # back when constructing the agent. diff --git a/src/octop/api/routers/memory_portable.py b/src/octop/api/routers/memory_portable.py index ab8f0076c5..30d7a281aa 100644 --- a/src/octop/api/routers/memory_portable.py +++ b/src/octop/api/routers/memory_portable.py @@ -51,7 +51,7 @@ def _refuse_postgres_portable(server: Any, agent_id: str) -> None: SQLite: one file per agent — pack/adopt moves that file's contents between hosts (Octop / OpenClaw / hermes). - PostgreSQL: all agents share fixed tables in the ``harness_memory`` schema, + PostgreSQL: all agents share fixed tables in the ``octop_memory`` schema, isolated by a ``namespace`` column. There is no per-agent file to pack, and ``pg_dump`` of the schema must NOT be suggested as a substitute — it would export every agent's memory, not just this one. The supported way to give @@ -102,13 +102,13 @@ async def list_portable_sources( ) -> JSONResponse: """List all migratable memory stores on this host.""" try: - from harness_memory.operations.migration.portable import list_sources + from octop_memory.operations.migration.portable import list_sources sources = list_sources() return JSONResponse(content={"sources": [s.to_dict() for s in sources]}) except ImportError: raise OctopError( - ErrorCode.INTERNAL_ERROR, "harness-memory 未安装,无法使用记忆迁移功能" + ErrorCode.INTERNAL_ERROR, "octop-memory 未安装,无法使用记忆迁移功能" ) from None except Exception as exc: logger.exception("list_portable_sources failed") @@ -132,9 +132,9 @@ async def pack_agent_memory( _refuse_postgres_portable(server, agent_id) try: - from harness_memory.operations.migration.portable import pack - from harness_memory.operations.migration.portable.models import SourceInfo - from harness_memory.operations.migration.portable.sources import _probe_db + from octop_memory.operations.migration.portable import pack + from octop_memory.operations.migration.portable.models import SourceInfo + from octop_memory.operations.migration.portable.sources import _probe_db # Resolve the agent's db path and namespace workspace = resolve_agent_workspace_dir(server, agent_id) @@ -182,7 +182,7 @@ def _iter_file() -> Any: ) except ImportError: - raise OctopError(ErrorCode.INTERNAL_ERROR, "harness-memory 未安装") from None + raise OctopError(ErrorCode.INTERNAL_ERROR, "octop-memory 未安装") from None except ValueError as exc: raise OctopError(ErrorCode.INTERNAL_ERROR, str(exc), status=400) from exc except Exception as exc: @@ -213,7 +213,7 @@ async def adopt_agent_memory( _refuse_postgres_portable(server, agent_id) try: - from harness_memory.operations.migration.portable import adopt + from octop_memory.operations.migration.portable import adopt # Write the uploaded file to a temp file with tempfile.NamedTemporaryFile(suffix=".hmpkg", delete=False) as tmp: @@ -237,7 +237,7 @@ async def adopt_agent_memory( return JSONResponse(content=summary.to_dict()) except ImportError: - raise OctopError(ErrorCode.INTERNAL_ERROR, "harness-memory 未安装") from None + raise OctopError(ErrorCode.INTERNAL_ERROR, "octop-memory 未安装") from None except ValueError as exc: raise OctopError(ErrorCode.INTERNAL_ERROR, str(exc), status=400) from exc except Exception as exc: @@ -264,7 +264,7 @@ async def doctor_agent_memory( _refuse_postgres_portable(server, agent_id) try: - from harness_memory.operations.migration.portable import doctor + from octop_memory.operations.migration.portable import doctor # Resolve the agent's db path workspace = resolve_agent_workspace_dir(server, agent_id) @@ -294,7 +294,7 @@ async def doctor_agent_memory( return JSONResponse(content=report.to_dict()) except ImportError: - raise OctopError(ErrorCode.INTERNAL_ERROR, "harness-memory 未安装") from None + raise OctopError(ErrorCode.INTERNAL_ERROR, "octop-memory 未安装") from None except Exception as exc: logger.exception("doctor_agent_memory failed for agent_id=%s", agent_id) raise OctopError(ErrorCode.INTERNAL_ERROR, str(exc)) from exc diff --git a/src/octop/api/routers/plugins.py b/src/octop/api/routers/plugins.py index 17aa28cf43..9199d38159 100644 --- a/src/octop/api/routers/plugins.py +++ b/src/octop/api/routers/plugins.py @@ -458,7 +458,7 @@ async def list_agent_plugin_tools( if isinstance(raw_tool, dict): tool_cfg = raw_tool # Default on when the agent has no explicit override (matches - # harness_agent.plugins.tools._tool_enabled). + # octop_harness.plugins.tools._tool_enabled). if tool_cfg and "enabled" in tool_cfg: tool_enabled = bool(tool_cfg.get("enabled")) else: diff --git a/src/octop/api/routers/providers.py b/src/octop/api/routers/providers.py index c38f3e4863..6cbbf04d86 100644 --- a/src/octop/api/routers/providers.py +++ b/src/octop/api/routers/providers.py @@ -142,7 +142,7 @@ def _row_to_dict(r: Any) -> dict[str, Any]: async def list_provider_presets( _: Any = Depends(current_user), ) -> list[dict[str, Any]]: - """Return built-in provider presets from harness-agent.""" + """Return built-in provider presets from octop-harness.""" return load_provider_presets() diff --git a/src/octop/api/routers/security.py b/src/octop/api/routers/security.py index bccc3a1416..b7a1551e0b 100644 --- a/src/octop/api/routers/security.py +++ b/src/octop/api/routers/security.py @@ -5,7 +5,7 @@ from typing import Any from fastapi import APIRouter, Depends, HTTPException -from harness_agent.security.models import DEFAULT_HITL_TOOLS, SecurityPolicy +from octop_harness.security.models import DEFAULT_HITL_TOOLS, SecurityPolicy from pydantic import BaseModel, Field from octop.api.deps import get_server, require_permission @@ -160,7 +160,7 @@ async def put_tool_guard_rules_raw( count, errors = store.save_text(body.content) if errors: raise HTTPException(status_code=400, detail={"errors": errors}) - registry.reload_harness_agents() + registry.reload_octop_harnesss() server.services.audit_repo.write( actor=ACTOR_ADMIN, action="security.tool_guard_rules.update", @@ -178,7 +178,7 @@ async def post_tool_guard_rules_reset( store = _rules_store(server) registry = server.app_runtime.agent_registry content = store.reset_to_bundled() - registry.reload_harness_agents() + registry.reload_octop_harnesss() server.services.audit_repo.write( actor=ACTOR_ADMIN, action="security.tool_guard_rules.reset", diff --git a/src/octop/api/routers/skills.py b/src/octop/api/routers/skills.py index d794d12a55..04c5d34ea1 100644 --- a/src/octop/api/routers/skills.py +++ b/src/octop/api/routers/skills.py @@ -1068,7 +1068,7 @@ async def _persist_disabled(server: Any, agent_id: str, disabled: set[str]) -> N async def _skill_disable_keys(ctx: _AgentCtx, name: str) -> set[str]: """Return slug / display-name keys to toggle for enable/disable.""" - from harness_agent.skills.catalog import skill_identity_keys + from octop_harness.skills.catalog import skill_identity_keys slug = name.strip() keys = {slug} if slug else set() diff --git a/src/octop/api/routers/workspace.py b/src/octop/api/routers/workspace.py index 875b5e750a..fd9db8c485 100644 --- a/src/octop/api/routers/workspace.py +++ b/src/octop/api/routers/workspace.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, File, Query, UploadFile from fastapi.responses import Response, StreamingResponse -from harness_agent.backends.utils import BackendOperationNotSupportedError +from octop_harness.backends.utils import BackendOperationNotSupportedError from pydantic import BaseModel from octop.api.common.agent_workspace import resolve_agent_workspace_dir @@ -339,7 +339,7 @@ async def download_file( See ``from_workspace``: workspace UI uses true; chat/tool downloads use false. ``file://`` and other host-absolute paths are allowed for agent/OS tool outputs (Desktop, ``~/.octop/agents/…``, workspace tree) but denied for - sensitive system roots (``/etc``, ``.harness-browser``, Windows system dirs). + sensitive system roots (``/etc``, ``.harness-browser``, ``.octop-browser``, Windows system dirs). """ ws = await require_running_workspace(agent_id, user=user, as_user=as_user, server=server) io_path = _workspace_io_path(path, from_workspace=from_workspace) diff --git a/src/octop/cli/commands/acp.py b/src/octop/cli/commands/acp.py index 870d69df9b..98eab10fc1 100644 --- a/src/octop/cli/commands/acp.py +++ b/src/octop/cli/commands/acp.py @@ -37,7 +37,7 @@ async def _run_acp_server(*, agent_id: str | None, debug: bool) -> None: except Exception as exc: raise click.ClickException(f"agent {aid!r} is not running: {exc}") from exc - from harness_agent.acp.server import run_harness_acp_server + from octop_harness.acp.server import run_harness_acp_server try: await run_harness_acp_server(harness, agent_id=aid) diff --git a/src/octop/cli/commands/models.py b/src/octop/cli/commands/models.py index 3d73dabb22..ecada5fd67 100644 --- a/src/octop/cli/commands/models.py +++ b/src/octop/cli/commands/models.py @@ -16,7 +16,7 @@ def models() -> None: @models.command("presets") def presets() -> None: - """List built-in provider templates from harness-agent.""" + """List built-in provider templates from octop-harness.""" from octop.cli.support.ctx import json_output_enabled from octop.cli.support.offline_ops import load_provider_presets_offline diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index 0cc4fdf184..8e30cd3ec9 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -126,7 +126,7 @@ "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.", "nothing": "Nothing to compact on thread `{short}` (too short or already compact).", "no_thread": "No active thread.", - "unavailable": "Compact unavailable (agent not ready or harness too old).", + "unavailable": "Compact unavailable (agent not ready or octop-harness too old).", "failed": "Compact failed; check logs and retry.", "archived": " ({count} messages archived as {short})" }, @@ -773,7 +773,7 @@ "unavailable": "Cannot connect to a running compatible Octop, or control connection lost: {detail}", "busy": "A memory maintenance job is already running.", "not_ready": "Octop runtime is not ready.", - "upgrade": "The loaded harness-memory package does not provide live maintenance. Install the matching version or load the local source, then restart Octop.", + "upgrade": "The loaded octop-memory package does not provide live maintenance. Install the matching version or load the local source, then restart Octop.", "wait_timeout": "The agent remained busy for 120 seconds; no migration was started.", "incompatible": "Live maintenance requires a compatible SQLite checkpointer; upgrade and restart Octop.", "interrupted": "Maintenance interrupted.", diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index 31174a8f25..1ed9037dae 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -773,7 +773,7 @@ "unavailable": "无法连接正在运行的新版 Octop,或控制连接中断:{detail}", "busy": "已有记忆库整理任务正在进行。", "not_ready": "Octop 运行环境尚未准备好。", - "upgrade": "当前加载的 harness-memory 未提供在线整理接口。请安装配套版本或加载本地源码后重启 Octop。", + "upgrade": "当前加载的 octop-memory 未提供在线整理接口。请安装配套版本或加载本地源码后重启 Octop。", "wait_timeout": "智能体持续繁忙超过 120 秒,本次未开始迁移。", "incompatible": "在线整理仅支持兼容的新版本 SQLite checkpointer,请更新并重启 Octop。", "interrupted": "整理被中断。", diff --git a/src/octop/infra/agents/acp_settings.py b/src/octop/infra/agents/acp_settings.py index ef5241da44..b223c56122 100644 --- a/src/octop/infra/agents/acp_settings.py +++ b/src/octop/infra/agents/acp_settings.py @@ -6,7 +6,7 @@ import logging from typing import Any -from harness_agent.acp.models import ACPConfig, default_acp_runners +from octop_harness.acp.models import ACPConfig, default_acp_runners from octop.infra.db.repos.agents import AgentRepo, AgentRow from octop.infra.db.repos.settings import SettingsRepo @@ -15,7 +15,7 @@ _SETTINGS_PREFIX = "acp_runners:user:" _HIDDEN_RUNNERS = frozenset({"qwen_code"}) -# Until orcakit-harness-agent ships these builtins, merge Octop-side defaults. +# Until octop-harness ships these builtins, merge Octop-side defaults. _OCTOP_BUILTIN_RUNNERS: dict[str, dict[str, Any]] = { "kimi_code": { "command": "kimi", diff --git a/src/octop/infra/agents/avatar.py b/src/octop/infra/agents/avatar.py index 012df52d7c..8da1b84a5c 100644 --- a/src/octop/infra/agents/avatar.py +++ b/src/octop/infra/agents/avatar.py @@ -7,7 +7,7 @@ from pathlib import Path, PurePosixPath from typing import Any, Protocol -from harness_agent.backends.utils import BackendOperationNotSupportedError +from octop_harness.backends.utils import BackendOperationNotSupportedError from octop.infra.errors import ErrorCode, OctopError from octop.infra.gateway.media.attachment_hints import sniff_image_media_type diff --git a/src/octop/infra/agents/builtin_skills/skill-manager/SKILL.md b/src/octop/infra/agents/builtin_skills/skill-manager/SKILL.md index e7d083930a..488aa5f9fc 100644 --- a/src/octop/infra/agents/builtin_skills/skill-manager/SKILL.md +++ b/src/octop/infra/agents/builtin_skills/skill-manager/SKILL.md @@ -14,7 +14,7 @@ metadata: # Skill Manager -只管理当前实例的 `{{OCTOP_SKILLS}}/`。当前实例工作区固定为 `{{OCTOP_WORKSPACE}}`;上传文件通常位于其中的 `inbound/`。系统文件(skills、内置 skills、会话等)在 `{{OCTOP_SKILLS}}` 所在目录。不要用 `pwd`、`$HOME`、`~/.harness-agent/workspace`、记忆文件或搜索结果重新猜测工作区。不要改其他实例、全局 Skill 目录或 `{{OCTOP_BUILTIN_SKILLS}}/`。 +只管理当前实例的 `{{OCTOP_SKILLS}}/`。当前实例工作区固定为 `{{OCTOP_WORKSPACE}}`;上传文件通常位于其中的 `inbound/`。系统文件(skills、内置 skills、会话等)在 `{{OCTOP_SKILLS}}` 所在目录。不要用 `pwd`、`$HOME`、`~/.octop-harness/workspace`、记忆文件或搜索结果重新猜测工作区。不要改其他实例、全局 Skill 目录或 `{{OCTOP_BUILTIN_SKILLS}}/`。 ## 操作入口 diff --git a/src/octop/infra/agents/context_breakdown.py b/src/octop/infra/agents/context_breakdown.py index f37844e27c..911b2fcfc1 100644 --- a/src/octop/infra/agents/context_breakdown.py +++ b/src/octop/infra/agents/context_breakdown.py @@ -20,8 +20,8 @@ ) try: - from harness_agent.context_usage import SEGMENT_KEYS as SEGMENT_KEYS -except ImportError: # pragma: no cover - older orcakit-harness-agent wheels + from octop_harness.context_usage import SEGMENT_KEYS as SEGMENT_KEYS +except ImportError: # pragma: no cover - older octop-harness wheels SEGMENT_KEYS = _FALLBACK_SEGMENT_KEYS __all__ = [ diff --git a/src/octop/infra/agents/conversation_mode.py b/src/octop/infra/agents/conversation_mode.py index b98a3a7d5a..2796502398 100644 --- a/src/octop/infra/agents/conversation_mode.py +++ b/src/octop/infra/agents/conversation_mode.py @@ -1,11 +1,11 @@ -"""Ask / Plan / Craft helpers for Octop (policy SoT lives in harness-agent).""" +"""Ask / Plan / Craft helpers for Octop (policy SoT lives in octop-harness).""" from __future__ import annotations import re from typing import Any -from harness_agent.middleware.conversation_mode import ( +from octop_harness.middleware.conversation_mode import ( CONFIG_EXTRA_READ_KEY, CONFIG_MODE_KEY, DEFAULT_CONVERSATION_MODE, diff --git a/src/octop/infra/agents/experts/catalog.py b/src/octop/infra/agents/experts/catalog.py index d012f1e7f5..fee8095ee2 100644 --- a/src/octop/infra/agents/experts/catalog.py +++ b/src/octop/infra/agents/experts/catalog.py @@ -18,7 +18,7 @@ from pathlib import Path from typing import Any, cast -from harness_agent.backends.workspace import BackendWorkspace +from octop_harness.backends.workspace import BackendWorkspace from octop.infra.agents.manager import AgentCreateSpec from octop.infra.agents.workspace_dir import DEFAULT_SYSTEM_FILES_PATH diff --git a/src/octop/infra/agents/experts/publish.py b/src/octop/infra/agents/experts/publish.py index f75c3387a3..79aa049470 100644 --- a/src/octop/infra/agents/experts/publish.py +++ b/src/octop/infra/agents/experts/publish.py @@ -11,7 +11,7 @@ from typing import Any from uuid import uuid4 -from harness_agent.backends.workspace import BackendWorkspace +from octop_harness.backends.workspace import BackendWorkspace from octop.infra.agents.avatar import copy_workspace_avatar_to_dir from octop.infra.agents.builtin_skills import OCTOP_BUILTIN_SKILLS_ROOT diff --git a/src/octop/infra/agents/langfuse.py b/src/octop/infra/agents/langfuse.py index d0b1cf8e8e..1d9b3ce8c2 100644 --- a/src/octop/infra/agents/langfuse.py +++ b/src/octop/infra/agents/langfuse.py @@ -149,8 +149,8 @@ async def test_connection( return await loop.run_in_executor(None, verify_langfuse_credentials, h, pk, sk) def harness_config(self) -> Any: - """Build harness-agent ``LangfuseConfig`` for ``HarnessAgentManager``.""" - from harness_agent.observability.langfuse import LangfuseConfig # noqa: PLC0415 + """Build octop-harness ``LangfuseConfig`` for ``HarnessAgentManager``.""" + from octop_harness.observability.langfuse import LangfuseConfig # noqa: PLC0415 view = self.load() if not view.enabled: diff --git a/src/octop/infra/agents/manager.py b/src/octop/infra/agents/manager.py index 80e865b924..92436fbfa7 100644 --- a/src/octop/infra/agents/manager.py +++ b/src/octop/infra/agents/manager.py @@ -15,9 +15,9 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, cast -from harness_agent import HarnessAgent, HarnessAgentConfig, HarnessAgentManager -from harness_agent.registry import AgentEntry -from harness_agent.security.models import SecurityPolicy +from octop_harness import HarnessAgent, HarnessAgentConfig, HarnessAgentManager +from octop_harness.registry import AgentEntry +from octop_harness.security.models import SecurityPolicy from octop.i18n.domains.agents import NO_MODELS_CONFIGURED, format_agent_start_error from octop.infra.agents.acp_settings import ACPSettingsStore @@ -96,7 +96,7 @@ # Bounded parallelism for awaited provider/active-model reload batches. _PROVIDER_RELOAD_CONCURRENCY = 6 -# harness-memory builds SQLite table names as ``{namespace}_*``. The namespace +# octop-memory builds SQLite table names as ``{namespace}_*``. The namespace # must be a valid bare SQL identifier: start with a letter, only [A-Za-z0-9_]. _MEMORY_NS_PREFIX = "agent_" @@ -201,7 +201,7 @@ def _memory_extract_settings( ): out[dst] = float(val) - # orcakit-harness-agent 0.9.5 predates the interval trigger fields. Keep + # octop-harness 0.9.5 predates the interval trigger fields. Keep # hot reload working against that release and approximate interval mode # with its per-session idle watchdog until a newer harness is installed. if ( @@ -213,7 +213,7 @@ def _memory_extract_settings( if isinstance(interval, int | float) and not isinstance(interval, bool) and interval > 0: out["memory_extract_idle_seconds"] = float(interval) logger.warning( - "Installed harness-agent lacks interval memory extraction; " + "Installed octop-harness lacks interval memory extraction; " "falling back to the idle watchdog for this agent" ) return out @@ -329,7 +329,7 @@ class AgentManager: - CRUD: create / update / delete - Reads: get_row, list_*, get_config, resolve_user_agent - Runtime: get_agent, stream / call / HITL / thread model - - Hot-reload: reload*, on_provider_changed, reload_harness_agents + - Hot-reload: reload*, on_provider_changed, reload_octop_harnesss - Connectors: reload_connectors*, prepare_chat_mcp - Settings stores: langfuse, security, acp_settings, tool_guard_rules, providers """ @@ -867,7 +867,7 @@ async def delete(self, agent_id: str) -> None: affected_teams = self._teams.drop_member(agent_id) except Exception: logger.exception("failed to drop deleted expert %s from team rosters", agent_id) - await asyncio.to_thread(self._quiesce_harness_memory, agent_id) + await asyncio.to_thread(self._quiesce_octop_memory, agent_id) await self._harness_manager.aremove_agent(agent_id) # type: ignore[union-attr] self._plugin_tool_labels.pop(agent_id, None) try: @@ -912,7 +912,7 @@ def _thread_execution_lock(self, agent_id: str, thread_id: str) -> asyncio.Lock: def _is_already_registered_error(exc: BaseException) -> bool: return "already exists in the registry" in str(exc) - def _harness_agent_or_none(self, agent_id: str) -> HarnessAgent | None: + def _octop_harness_or_none(self, agent_id: str) -> HarnessAgent | None: if self._harness_manager is None: return None try: @@ -927,14 +927,14 @@ async def stop(self, agent_id: str) -> None: if row is None: raise OctopError(ErrorCode.AGENT_NOT_FOUND, f"agent {agent_id!r} not found") async with self._lifecycle_lock_for(agent_id): - await asyncio.to_thread(self._quiesce_harness_memory, agent_id) + await asyncio.to_thread(self._quiesce_octop_memory, agent_id) await self._harness_manager.aremove_agent(agent_id) # type: ignore[union-attr] self._repos.agent_repo.set_state(agent_id, "stopped", error=None) - def _quiesce_harness_memory(self, agent_id: str) -> None: + def _quiesce_octop_memory(self, agent_id: str) -> None: """Stop memory GC before the harness closes the SQLite backend. - ``harness-memory`` runs lifecycle GC on a daemon thread. Closing the + ``octop-memory`` runs lifecycle GC on a daemon thread. Closing the store while ``list_candidates`` is in flight segfaults (Linux live CI and Windows unit tests with a real HarnessAgentManager). """ @@ -1223,7 +1223,7 @@ async def _track_invocation(self, agent_id: str) -> AsyncIterator[None]: self._end_invocation(agent_id) async def stream(self, agent_id: str, request: dict[str, Any]) -> AsyncIterator[Any]: - """Stream harness chunks (Langfuse tracing handled inside harness-agent).""" + """Stream harness chunks (Langfuse tracing handled inside octop-harness).""" if self._harness_manager is None: raise self._unavailable_error(agent_id) @@ -1275,7 +1275,7 @@ async def resume_hitl( self._apply_pending_bootstrap_graph_refresh(agent_id) def cancel_stream(self, agent_id: str, thread_id: str) -> None: - """Signal harness-agent to stop the active stream for *(agent_id, thread_id)*.""" + """Signal octop-harness to stop the active stream for *(agent_id, thread_id)*.""" if self._harness_manager is not None: self._harness_manager.cancel(agent_id, thread_id) @@ -1316,7 +1316,7 @@ async def reload_all(self) -> None: ] await self._reload_agents(agent_ids) - def reload_harness_agents(self) -> None: + def reload_octop_harnesss(self) -> None: """Rebuild harness agents in place (e.g. after tool-guard rules changed on disk). Does not rebuild Octop-side agent config from the DB — use :meth:`reload` for that. @@ -1507,7 +1507,7 @@ async def _get_or_load_mcp_tools( spec: dict[str, Any], ) -> list[Any]: """Load custom MCP tools once per user/server/fingerprint; share across agents.""" - from harness_agent.mcp import aload_mcp_tools + from octop_harness.mcp import aload_mcp_tools from octop.infra.connectors.mcp_tool_cache import ( fingerprint_mcp_spec, @@ -1708,7 +1708,7 @@ async def prepare_chat_mcp( ) still_builtin = sorted(set(still_builtin)) if still_builtin: - from harness_agent.mcp import aload_mcp_tools + from octop_harness.mcp import aload_mcp_tools from octop.infra.utils.env_file import ( env_file_path, @@ -2286,7 +2286,7 @@ async def list_skill_summaries( slug, row.get("error") or "corrupt", ) - from harness_agent.skills import catalog as harness_skill_catalog # noqa: PLC0415 + from octop_harness.skills import catalog as harness_skill_catalog # noqa: PLC0415 if getattr(harness_skill_catalog, "SKILL_PRESENTATION_METADATA_VERSION", 0) < 1: workspace = getattr(agent, "workspace", None) @@ -2366,7 +2366,7 @@ async def list_skill_summaries( return rows async def list_subagent_summaries(self, agent_id: str) -> list[dict[str, Any]]: - """Installed subagents for *agent_id* (delegates to harness-agent catalog).""" + """Installed subagents for *agent_id* (delegates to octop-harness catalog).""" agent = self.get_agent(agent_id) rows = [dict(row) for row in await agent.list_subagent_summaries()] await _fill_missing_subagent_colors(agent, rows) @@ -2412,7 +2412,7 @@ def strip_team_host_runtime_tools(self, agent_id: str) -> None: def sync_effective_tools_disabled(self, agent_id: str) -> None: """Hot-sync builtin + plugin denylist derived from current agent config.""" - from harness_agent.plugins import PluginRegistry + from octop_harness.plugins import PluginRegistry from octop.infra.agents.teams import host_tools_disabled, is_team_agent from octop.infra.agents.tool_catalog import effective_tools_disabled @@ -2477,7 +2477,7 @@ async def _start_agent( self._repos.agent_repo.set_state(row.agent_id, "failed", error=NO_MODELS_CONFIGURED) return None async with self._lifecycle_lock_for(row.agent_id): - existing = self._harness_agent_or_none(row.agent_id) + existing = self._octop_harness_or_none(row.agent_id) if existing is not None: # Idempotent: a concurrent reload/boot may have already registered. self._repos.agent_repo.set_state(row.agent_id, "running", error=None) @@ -2506,7 +2506,7 @@ async def _start_agent( logger.info("Agent %s (%s) started", row.agent_id, row.name) return entry.agent except Exception as exc: - recovered = self._harness_agent_or_none(row.agent_id) + recovered = self._octop_harness_or_none(row.agent_id) if recovered is not None and self._is_already_registered_error(exc): self._repos.agent_repo.set_state(row.agent_id, "running", error=None) logger.warning( @@ -2689,8 +2689,8 @@ def _backend_workspace_for_row( allow_ephemeral_remote: bool = False, ) -> Any: """Resolve :class:`BackendWorkspace` for *row* without a running harness agent.""" - from harness_agent.backends import resolve_backend # noqa: PLC0415 - from harness_agent.backends.workspace import BackendWorkspace # noqa: PLC0415 + from octop_harness.backends import resolve_backend # noqa: PLC0415 + from octop_harness.backends.workspace import BackendWorkspace # noqa: PLC0415 from octop.infra.agents.workspace_dir import system_files_path_from_config # noqa: PLC0415 from octop.infra.backend.opensandbox_deps import ensure_opensandbox_deps # noqa: PLC0415 @@ -2794,7 +2794,7 @@ async def _reload_agent(self, agent_id: str) -> None: self._bootstrap_graph_refresh_pending.discard(agent_id) row = self._repos.agent_repo.get(agent_id) if not row or not row.enabled or row.last_state == "stopped": - await asyncio.to_thread(self._quiesce_harness_memory, agent_id) + await asyncio.to_thread(self._quiesce_octop_memory, agent_id) await self._harness_manager.aremove_agent(agent_id) return if self._harness_manager.shared_factory is None: @@ -2810,7 +2810,7 @@ async def _reload_agent(self, agent_id: str) -> None: await self._post_start_agent(row, entry.agent, cfg, user_display=user_display) self._repos.agent_repo.set_state(agent_id, "running", error=None) except Exception as exc: - recovered = self._harness_agent_or_none(agent_id) + recovered = self._octop_harness_or_none(agent_id) if recovered is not None and self._is_already_registered_error(exc): self._repos.agent_repo.set_state(agent_id, "running", error=None) logger.warning( @@ -2959,7 +2959,7 @@ def _connector_uid_for( return row.user_id def _prepare_stream_request(self, agent_id: str, request: dict[str, Any]) -> dict[str, Any]: - from harness_agent.plugins import collect_plugin_tool_configs # noqa: PLC0415 + from octop_harness.plugins import collect_plugin_tool_configs # noqa: PLC0415 req = dict(request) if req.get("agent_id") is None: @@ -2977,7 +2977,7 @@ def _prepare_stream_request(self, agent_id: str, request: dict[str, Any]) -> dic def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: """Convert an AgentRow into a HarnessAgentConfig.""" - from harness_agent.middleware.bootstrap import bootstrap_marker_exists # noqa: PLC0415 + from octop_harness.middleware.bootstrap import bootstrap_marker_exists # noqa: PLC0415 from octop.infra.agents.workspace_dir import ( # noqa: PLC0415 harness_workspace_path, @@ -3041,7 +3041,7 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: paths=self.paths, ) - from harness_agent.plugins import PluginRegistry, build_plugin_tools # noqa: PLC0415 + from octop_harness.plugins import PluginRegistry, build_plugin_tools # noqa: PLC0415 from octop.infra.agents.plugin_tool_defaults import ( # noqa: PLC0415 agent_plugin_enabled, @@ -3110,7 +3110,7 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: ) from octop.infra.knowledge.hint import KnowledgeSearchHintMiddleware - # FilesystemGuard + ModelSettings live in harness-agent (auto-mounted). + # FilesystemGuard + ModelSettings live in octop-harness (auto-mounted). # 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. @@ -3141,7 +3141,7 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: acp_section = cfg.get("acp") acp_raw: dict[str, Any] = acp_section if isinstance(acp_section, dict) else {} - from harness_agent.acp.models import ACPConfig + from octop_harness.acp.models import ACPConfig if team_host: acp_raw = {} diff --git a/src/octop/infra/agents/media_generation.py b/src/octop/infra/agents/media_generation.py index af72c15097..3f7ba321b4 100644 --- a/src/octop/infra/agents/media_generation.py +++ b/src/octop/infra/agents/media_generation.py @@ -17,7 +17,7 @@ from octop.infra.errors import ErrorCode, OctopError if TYPE_CHECKING: - from harness_agent import MediaGenerationConfig + from octop_harness import MediaGenerationConfig MediaProviderName = Literal["volcengine", "dashscope", "minimax"] MediaTestKind = Literal["image", "video"] @@ -287,8 +287,8 @@ async def verify_media_model( kind: MediaTestKind, ) -> dict[str, object]: """Submit one provider-neutral model probe through the harness adapter.""" - from harness_agent import MediaProviderConfig # noqa: PLC0415 - from harness_agent.media import ( # noqa: PLC0415 + from octop_harness import MediaProviderConfig # noqa: PLC0415 + from octop_harness.media import ( # noqa: PLC0415 ImageGenerationRequest, MediaGenerationError, VideoGenerationRequest, @@ -571,7 +571,7 @@ def harness_config(self) -> MediaGenerationConfig | None: if not view.configured: return None - from harness_agent import ( # noqa: PLC0415 + from octop_harness import ( # noqa: PLC0415 MediaGenerationConfig, MediaProviderConfig, ) diff --git a/src/octop/infra/agents/memory_backend.py b/src/octop/infra/agents/memory_backend.py index 6c9cde89d3..b15200a159 100644 --- a/src/octop/infra/agents/memory_backend.py +++ b/src/octop/infra/agents/memory_backend.py @@ -1,4 +1,4 @@ -"""Resolve agent memory storage backend for harness-agent / harness-memory.""" +"""Resolve agent memory storage backend for octop-harness / octop-memory.""" from __future__ import annotations diff --git a/src/octop/infra/agents/memory_slim.py b/src/octop/infra/agents/memory_slim.py index b7aa04b3ad..c38be7c274 100644 --- a/src/octop/infra/agents/memory_slim.py +++ b/src/octop/infra/agents/memory_slim.py @@ -54,14 +54,14 @@ def list_agents(self, *, locale: str = "en") -> list[dict[str, str]]: @staticmethod def _require_live_support(locale: str) -> None: - from harness_memory.application import checkpoint_maintenance + from octop_memory.application import checkpoint_maintenance if not callable(getattr(checkpoint_maintenance, "slim_live_checkpoints", None)): raise ValueError(tr("memory_slim.upgrade", locale)) def _require_memory(self, agent_id: str, locale: str) -> Any: - from harness_memory.storage.backends.sqlite import SqliteMemoryBackend - from harness_memory.storage.backends.sqlite_checkpoint import CompactSqliteSaver + from octop_memory.storage.backends.sqlite import SqliteMemoryBackend + from octop_memory.storage.backends.sqlite_checkpoint import CompactSqliteSaver try: agent = self._agent_manager.get_agent(agent_id) @@ -196,7 +196,7 @@ async def _run(self, agent_id: str, locale: str, *, owner_id: int | None = None) registry = self._agent_manager reserved = False try: - from harness_memory.application import checkpoint_maintenance + from octop_memory.application import checkpoint_maintenance slim_live_checkpoints = getattr(checkpoint_maintenance, "slim_live_checkpoints", None) if not callable(slim_live_checkpoints): diff --git a/src/octop/infra/agents/middleware/workspace_image.py b/src/octop/infra/agents/middleware/workspace_image.py index f37e7a787a..6307507dc2 100644 --- a/src/octop/infra/agents/middleware/workspace_image.py +++ b/src/octop/infra/agents/middleware/workspace_image.py @@ -19,7 +19,7 @@ ) if TYPE_CHECKING: - from harness_agent.backends.workspace import BackendWorkspace + from octop_harness.backends.workspace import BackendWorkspace class WorkspaceImageMaterializeMiddleware(AgentMiddleware[Any, Any]): diff --git a/src/octop/infra/agents/plugin_tool_defaults.py b/src/octop/infra/agents/plugin_tool_defaults.py index ff483d023d..7624ab489e 100644 --- a/src/octop/infra/agents/plugin_tool_defaults.py +++ b/src/octop/infra/agents/plugin_tool_defaults.py @@ -5,7 +5,7 @@ opposite: once a plugin is globally enabled, its tools are available unless the agent opts out with ``enabled: false``. -Until ``orcakit-harness-agent`` ships matching defaults, Octop expands the +Until ``octop-harness`` ships matching defaults, Octop expands the agent plugins map before calling ``build_plugin_tools``. """ diff --git a/src/octop/infra/agents/plugin_tool_names.py b/src/octop/infra/agents/plugin_tool_names.py index a101f7d4f4..1df5f47363 100644 --- a/src/octop/infra/agents/plugin_tool_names.py +++ b/src/octop/infra/agents/plugin_tool_names.py @@ -3,7 +3,7 @@ Plugin authors register tools with ``ctx.tool("中文名", fn, ...)``; the harness passes that name straight into the function-calling schema, but most LLM APIs only accept ``^[a-zA-Z0-9_-]{1,64}$``. Mirroring the MCP-side fix -(``harness_agent.mcp.sanitize_llm_tool_name``) this module rewrites non-conforming +(``octop_harness.mcp.sanitize_llm_tool_name``) this module rewrites non-conforming plugin tool names to legal ASCII names: - CJK characters are transliterated to pinyin (``天气查询`` -> ``tianqichaxun``) diff --git a/src/octop/infra/agents/plugins/bundled/air-quality/main.py b/src/octop/infra/agents/plugins/bundled/air-quality/main.py index 5d5ea5e9d7..a1350632b1 100644 --- a/src/octop/infra/agents/plugins/bundled/air-quality/main.py +++ b/src/octop/infra/agents/plugins/bundled/air-quality/main.py @@ -6,7 +6,7 @@ from typing import Any import httpx -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext def _payload(data: dict[str, Any], text: str) -> str: diff --git a/src/octop/infra/agents/plugins/bundled/bilibili-anime/main.py b/src/octop/infra/agents/plugins/bundled/bilibili-anime/main.py index cfa8a138b6..50c231edcf 100644 --- a/src/octop/infra/agents/plugins/bundled/bilibili-anime/main.py +++ b/src/octop/infra/agents/plugins/bundled/bilibili-anime/main.py @@ -12,7 +12,7 @@ from typing import Any import httpx -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext logger = logging.getLogger("octop.plugins.bilibili_anime") diff --git a/src/octop/infra/agents/plugins/bundled/color-convert/main.py b/src/octop/infra/agents/plugins/bundled/color-convert/main.py index 8bcdc9cb25..b1ed488482 100644 --- a/src/octop/infra/agents/plugins/bundled/color-convert/main.py +++ b/src/octop/infra/agents/plugins/bundled/color-convert/main.py @@ -6,7 +6,7 @@ import re from typing import Any -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext _HEX3 = re.compile(r"^#([0-9a-fA-F]{3})$") _HEX6 = re.compile(r"^#([0-9a-fA-F]{6})$") diff --git a/src/octop/infra/agents/plugins/bundled/daily-english/main.py b/src/octop/infra/agents/plugins/bundled/daily-english/main.py index a4361fd8ca..6c508e1f29 100644 --- a/src/octop/infra/agents/plugins/bundled/daily-english/main.py +++ b/src/octop/infra/agents/plugins/bundled/daily-english/main.py @@ -7,7 +7,7 @@ from datetime import date from typing import Any -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext _VOCAB: tuple[tuple[str, str, str, str], ...] = ( ( diff --git a/src/octop/infra/agents/plugins/bundled/fortune/main.py b/src/octop/infra/agents/plugins/bundled/fortune/main.py index 2b9f7c9173..d29a7b1446 100644 --- a/src/octop/infra/agents/plugins/bundled/fortune/main.py +++ b/src/octop/infra/agents/plugins/bundled/fortune/main.py @@ -9,7 +9,7 @@ from datetime import date from typing import Any -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext _DICE_RE = re.compile(r"^\s*(\d+)\s*[dD]\s*(\d+)\s*$") _FACES = {4, 6, 8, 10, 12, 20, 100} diff --git a/src/octop/infra/agents/plugins/bundled/fun-facts/main.py b/src/octop/infra/agents/plugins/bundled/fun-facts/main.py index 0f0db8271a..444fef31b0 100644 --- a/src/octop/infra/agents/plugins/bundled/fun-facts/main.py +++ b/src/octop/infra/agents/plugins/bundled/fun-facts/main.py @@ -6,7 +6,7 @@ import random from typing import Any -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext _FACTS = ( "蜂鸟是唯一可以倒飞的鸟类。", diff --git a/src/octop/infra/agents/plugins/bundled/github-trending/main.py b/src/octop/infra/agents/plugins/bundled/github-trending/main.py index a524b34ba9..92b3eb9ac5 100644 --- a/src/octop/infra/agents/plugins/bundled/github-trending/main.py +++ b/src/octop/infra/agents/plugins/bundled/github-trending/main.py @@ -7,7 +7,7 @@ from typing import Any import httpx -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext _UA = "Octop-github-trending/0.1.0" diff --git a/src/octop/infra/agents/plugins/bundled/hot-topics/main.py b/src/octop/infra/agents/plugins/bundled/hot-topics/main.py index 8b9c8b2e08..ab1b726de8 100644 --- a/src/octop/infra/agents/plugins/bundled/hot-topics/main.py +++ b/src/octop/infra/agents/plugins/bundled/hot-topics/main.py @@ -6,7 +6,7 @@ from typing import Any import httpx -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext _UA = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " diff --git a/src/octop/infra/agents/plugins/bundled/ip-lookup/main.py b/src/octop/infra/agents/plugins/bundled/ip-lookup/main.py index 77a514f8df..ab918ffc9e 100644 --- a/src/octop/infra/agents/plugins/bundled/ip-lookup/main.py +++ b/src/octop/infra/agents/plugins/bundled/ip-lookup/main.py @@ -6,7 +6,7 @@ from typing import Any import httpx -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext def _payload(data: dict[str, Any], text: str) -> str: diff --git a/src/octop/infra/agents/plugins/bundled/market-quotes/main.py b/src/octop/infra/agents/plugins/bundled/market-quotes/main.py index 61f6489253..f3c752de94 100644 --- a/src/octop/infra/agents/plugins/bundled/market-quotes/main.py +++ b/src/octop/infra/agents/plugins/bundled/market-quotes/main.py @@ -7,7 +7,7 @@ from typing import Any import httpx -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext _UA = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " diff --git a/src/octop/infra/agents/plugins/bundled/meme-maker/main.py b/src/octop/infra/agents/plugins/bundled/meme-maker/main.py index 8ef9bfbca6..abb7981a61 100644 --- a/src/octop/infra/agents/plugins/bundled/meme-maker/main.py +++ b/src/octop/infra/agents/plugins/bundled/meme-maker/main.py @@ -6,7 +6,7 @@ from typing import Any import httpx -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext _TEMPLATES: tuple[tuple[str, str, str], ...] = ( ("doge", "Doge", "经典柴犬"), diff --git a/src/octop/infra/agents/plugins/bundled/mini-games/main.py b/src/octop/infra/agents/plugins/bundled/mini-games/main.py index d5d28928de..8e77b9d544 100644 --- a/src/octop/infra/agents/plugins/bundled/mini-games/main.py +++ b/src/octop/infra/agents/plugins/bundled/mini-games/main.py @@ -6,7 +6,7 @@ import random from typing import Any -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext _WINS = ( (0, 1, 2), diff --git a/src/octop/infra/agents/plugins/bundled/movie-search/main.py b/src/octop/infra/agents/plugins/bundled/movie-search/main.py index 734216c267..7426b7f41a 100644 --- a/src/octop/infra/agents/plugins/bundled/movie-search/main.py +++ b/src/octop/infra/agents/plugins/bundled/movie-search/main.py @@ -7,7 +7,7 @@ from urllib.parse import quote import httpx -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext _UA = "Octop-movie-search/0.1.0 (https://github.com/octop)" diff --git a/src/octop/infra/agents/plugins/bundled/parcel-tracker/main.py b/src/octop/infra/agents/plugins/bundled/parcel-tracker/main.py index 1e9e402e6b..4c4ccd7813 100644 --- a/src/octop/infra/agents/plugins/bundled/parcel-tracker/main.py +++ b/src/octop/infra/agents/plugins/bundled/parcel-tracker/main.py @@ -7,7 +7,7 @@ from urllib.parse import quote import httpx -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext _HEADERS = { "User-Agent": ( diff --git a/src/octop/infra/agents/plugins/bundled/pomodoro/main.py b/src/octop/infra/agents/plugins/bundled/pomodoro/main.py index f15ec69ac6..143a3a2753 100644 --- a/src/octop/infra/agents/plugins/bundled/pomodoro/main.py +++ b/src/octop/infra/agents/plugins/bundled/pomodoro/main.py @@ -6,7 +6,7 @@ from datetime import UTC, datetime from typing import Any -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext def _payload(data: dict[str, Any], text: str) -> str: diff --git a/src/octop/infra/agents/plugins/bundled/qrcode/main.py b/src/octop/infra/agents/plugins/bundled/qrcode/main.py index 5372185242..ea82c5c82f 100644 --- a/src/octop/infra/agents/plugins/bundled/qrcode/main.py +++ b/src/octop/infra/agents/plugins/bundled/qrcode/main.py @@ -8,7 +8,7 @@ from typing import Any import segno -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext _MAX_LEN = 2048 diff --git a/src/octop/infra/agents/plugins/bundled/server-status/main.py b/src/octop/infra/agents/plugins/bundled/server-status/main.py index 466c8994e9..68a9e979c7 100644 --- a/src/octop/infra/agents/plugins/bundled/server-status/main.py +++ b/src/octop/infra/agents/plugins/bundled/server-status/main.py @@ -11,7 +11,7 @@ from typing import Any import psutil -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext def _bytes_human(n: int | float) -> str: diff --git a/src/octop/infra/agents/plugins/bundled/short-link/main.py b/src/octop/infra/agents/plugins/bundled/short-link/main.py index 225581efed..aa798b0bd5 100644 --- a/src/octop/infra/agents/plugins/bundled/short-link/main.py +++ b/src/octop/infra/agents/plugins/bundled/short-link/main.py @@ -6,7 +6,7 @@ from typing import Any import httpx -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext def _payload(data: dict[str, Any], text: str) -> str: diff --git a/src/octop/infra/agents/plugins/bundled/slack-calendar/main.py b/src/octop/infra/agents/plugins/bundled/slack-calendar/main.py index 9fbb5c7f3c..62ff7b27ed 100644 --- a/src/octop/infra/agents/plugins/bundled/slack-calendar/main.py +++ b/src/octop/infra/agents/plugins/bundled/slack-calendar/main.py @@ -7,7 +7,7 @@ from datetime import date from typing import Any -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext # (start, end inclusive, name) _HOLIDAYS: list[tuple[date, date, str]] = [ diff --git a/src/octop/infra/agents/plugins/bundled/sports-scores/main.py b/src/octop/infra/agents/plugins/bundled/sports-scores/main.py index 094586c8d2..8ae13d2e3a 100644 --- a/src/octop/infra/agents/plugins/bundled/sports-scores/main.py +++ b/src/octop/infra/agents/plugins/bundled/sports-scores/main.py @@ -6,7 +6,7 @@ from typing import Any import httpx -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext _BASE = "https://www.thesportsdb.com/api/v1/json/3" _EPL_LEAGUE = "4328" diff --git a/src/octop/infra/agents/plugins/bundled/tetris/main.py b/src/octop/infra/agents/plugins/bundled/tetris/main.py index 8a402bddf9..6c5747a3a3 100644 --- a/src/octop/infra/agents/plugins/bundled/tetris/main.py +++ b/src/octop/infra/agents/plugins/bundled/tetris/main.py @@ -4,7 +4,7 @@ import json -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext async def start_tetris() -> str: diff --git a/src/octop/infra/agents/plugins/bundled/travel-inspire/main.py b/src/octop/infra/agents/plugins/bundled/travel-inspire/main.py index 3eae77bc44..4d649dfabd 100644 --- a/src/octop/infra/agents/plugins/bundled/travel-inspire/main.py +++ b/src/octop/infra/agents/plugins/bundled/travel-inspire/main.py @@ -8,7 +8,7 @@ from datetime import date from typing import Any -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext _DESTINATIONS: tuple[dict[str, Any], ...] = ( { diff --git a/src/octop/infra/agents/plugins/bundled/unit-convert/main.py b/src/octop/infra/agents/plugins/bundled/unit-convert/main.py index ce531c9412..aec569d8f2 100644 --- a/src/octop/infra/agents/plugins/bundled/unit-convert/main.py +++ b/src/octop/infra/agents/plugins/bundled/unit-convert/main.py @@ -5,7 +5,7 @@ import json from typing import Any -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext _LENGTH_TO_M: dict[str, float] = { "m": 1.0, diff --git a/src/octop/infra/agents/plugins/bundled/weather/main.py b/src/octop/infra/agents/plugins/bundled/weather/main.py index 10516acc4a..449524e312 100644 --- a/src/octop/infra/agents/plugins/bundled/weather/main.py +++ b/src/octop/infra/agents/plugins/bundled/weather/main.py @@ -6,7 +6,7 @@ from typing import Any import httpx -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext _WMO: dict[int, str] = { 0: "晴", diff --git a/src/octop/infra/agents/plugins/bundled/what-to-eat/main.py b/src/octop/infra/agents/plugins/bundled/what-to-eat/main.py index 82caf229b5..bdf157c8f9 100644 --- a/src/octop/infra/agents/plugins/bundled/what-to-eat/main.py +++ b/src/octop/infra/agents/plugins/bundled/what-to-eat/main.py @@ -8,7 +8,7 @@ from datetime import date from typing import Any -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext _MEALS: dict[str, tuple[str, ...]] = { "breakfast": ("豆浆油条", "小笼包", "粥配咸菜", "三明治", "燕麦酸奶"), diff --git a/src/octop/infra/agents/plugins/bundled/wiki-summary/main.py b/src/octop/infra/agents/plugins/bundled/wiki-summary/main.py index 266edad30e..1cfbf77f7a 100644 --- a/src/octop/infra/agents/plugins/bundled/wiki-summary/main.py +++ b/src/octop/infra/agents/plugins/bundled/wiki-summary/main.py @@ -7,7 +7,7 @@ from urllib.parse import quote import httpx -from harness_agent.plugins import PluginContext +from octop_harness.plugins import PluginContext _UA = "Octop-wiki-summary/0.1.0" diff --git a/src/octop/infra/agents/plugins/manager.py b/src/octop/infra/agents/plugins/manager.py index bb2979ff15..f4f7e47631 100644 --- a/src/octop/infra/agents/plugins/manager.py +++ b/src/octop/infra/agents/plugins/manager.py @@ -15,7 +15,7 @@ from urllib.parse import urlparse import yaml -from harness_agent.plugins import ( +from octop_harness.plugins import ( LoadedPlugin, PluginManifest, PluginRegistry, @@ -732,7 +732,7 @@ def sync_skills_to_workspace( *, agent_plugins: object = None, ) -> None: - from harness_agent.backends.workspace import BackendWorkspace + from octop_harness.backends.workspace import BackendWorkspace from octop.infra.agents.plugin_tool_defaults import agent_plugin_enabled diff --git a/src/octop/infra/agents/profile.py b/src/octop/infra/agents/profile.py index 53ebbcd691..1a8b4e84f9 100644 --- a/src/octop/infra/agents/profile.py +++ b/src/octop/infra/agents/profile.py @@ -1,6 +1,6 @@ """Agent profile fields stored on ``agents`` rather than in ``config_json``. -``config_json`` keeps harness-agent interaction keys (backend, plugins, memory, +``config_json`` keeps octop-harness interaction keys (backend, plugins, memory, skills, heartbeat, runtime knobs). Display / catalog metadata lives on columns. """ diff --git a/src/octop/infra/agents/providers/harness_factory.py b/src/octop/infra/agents/providers/harness_factory.py index 66d91ccafb..02f49c34a0 100644 --- a/src/octop/infra/agents/providers/harness_factory.py +++ b/src/octop/infra/agents/providers/harness_factory.py @@ -5,10 +5,10 @@ import logging from typing import TYPE_CHECKING, Any -from harness_agent.config import ProviderConfig +from octop_harness.config import ProviderConfig if TYPE_CHECKING: - from harness_agent import HarnessAgentManager + from octop_harness import HarnessAgentManager logger = logging.getLogger(__name__) diff --git a/src/octop/infra/agents/providers/presets.py b/src/octop/infra/agents/providers/presets.py index 039bf3a69f..b1e6df0350 100644 --- a/src/octop/infra/agents/providers/presets.py +++ b/src/octop/infra/agents/providers/presets.py @@ -1,4 +1,4 @@ -"""Built-in provider template loading (harness-agent bundles).""" +"""Built-in provider template loading (octop-harness bundles).""" from __future__ import annotations @@ -212,12 +212,12 @@ def _reasoning_profile(provider_id: str, model_id: str) -> dict[str, Any] | None def load_provider_presets() -> list[dict[str, Any]]: - """Serialize harness-agent provider templates for API / CLI.""" + """Serialize octop-harness provider templates for API / CLI.""" from importlib import resources - from harness_agent.providers import load_provider_templates, serialize_provider_preset + from octop_harness.providers import load_provider_templates, serialize_provider_preset - bundled = resources.files("harness_agent.providers").joinpath("provider_template.json") + bundled = resources.files("octop_harness.providers").joinpath("provider_template.json") out = [serialize_provider_preset(p) for p in load_provider_templates(str(bundled))] if not any(p.get("id") == "openai-codex" for p in out): out.insert( diff --git a/src/octop/infra/agents/providers/probe.py b/src/octop/infra/agents/providers/probe.py index e09e91044d..8882efbda3 100644 --- a/src/octop/infra/agents/providers/probe.py +++ b/src/octop/infra/agents/providers/probe.py @@ -41,8 +41,8 @@ def _is_codex_base_url(base_url: str | None) -> bool: def build_probe_chat_model(row: Any, *, model_id: str | None = None) -> Any: """Construct a chat model from a provider row for probing.""" - from harness_agent.config import ModelConfig, ProviderConfig - from harness_agent.llm.factory import build_chat_model + from octop_harness.config import ModelConfig, ProviderConfig + from octop_harness.llm.factory import build_chat_model protocol = KIND_TO_PROTOCOL.get(row.kind, row.kind) base_url = row.base_url or "https://api.openai.com/v1" diff --git a/src/octop/infra/agents/providers/store.py b/src/octop/infra/agents/providers/store.py index 08349b5184..552f96c11b 100644 --- a/src/octop/infra/agents/providers/store.py +++ b/src/octop/infra/agents/providers/store.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Iterator from typing import TYPE_CHECKING, Any -from harness_agent.config import ModelConfig, ProviderConfig +from octop_harness.config import ModelConfig, ProviderConfig from octop.infra.agents.providers.model_flags import is_chat_eligible_model, is_vision_model from octop.infra.agents.providers.opencode_session import session_header_for_provider diff --git a/src/octop/infra/agents/runtime_limits.py b/src/octop/infra/agents/runtime_limits.py index 2f07cd657e..bc474b7ada 100644 --- a/src/octop/infra/agents/runtime_limits.py +++ b/src/octop/infra/agents/runtime_limits.py @@ -1,4 +1,4 @@ -"""Map agent ``config_json`` runtime knobs onto harness-agent semantics. +"""Map agent ``config_json`` runtime knobs onto octop-harness semantics. Configurable stream contract (``ChatRequest.configurable``) ----------------------------------------------------------- @@ -14,7 +14,7 @@ from typing import Any -from harness_agent.middleware.model_settings import ( +from octop_harness.middleware.model_settings import ( CONFIGURABLE_MAX_INPUT_TOKENS, CONFIGURABLE_MODEL_SETTINGS, ) diff --git a/src/octop/infra/agents/security/policy_store.py b/src/octop/infra/agents/security/policy_store.py index fd4dbb88e7..81e33ec216 100644 --- a/src/octop/infra/agents/security/policy_store.py +++ b/src/octop/infra/agents/security/policy_store.py @@ -6,7 +6,7 @@ import logging from typing import Any -from harness_agent.security.models import SecurityPolicy +from octop_harness.security.models import SecurityPolicy from octop.infra.db.repos.settings import SettingsRepo diff --git a/src/octop/infra/agents/security/tool_guard_rules.py b/src/octop/infra/agents/security/tool_guard_rules.py index 99f0d310a4..9c1a55f7f7 100644 --- a/src/octop/infra/agents/security/tool_guard_rules.py +++ b/src/octop/infra/agents/security/tool_guard_rules.py @@ -3,7 +3,7 @@ import logging from pathlib import Path -from harness_agent.security.tool_guard.rule_guardian import ( +from octop_harness.security.tool_guard.rule_guardian import ( list_guard_rule_catalog, read_bundled_rules_yaml, validate_rules_yaml, diff --git a/src/octop/infra/agents/teams/team_manager.py b/src/octop/infra/agents/teams/team_manager.py index 2085a65d3d..057bbc69f2 100644 --- a/src/octop/infra/agents/teams/team_manager.py +++ b/src/octop/infra/agents/teams/team_manager.py @@ -18,16 +18,16 @@ from dataclasses import fields from typing import TYPE_CHECKING, Any -from harness_agent.messages import extract_call_response -from harness_agent.teams.inbox import InboxMessage -from harness_agent.teams.processor import ReplyEvent -from harness_agent.teams.util import ( +from langchain_core.messages import AIMessage, HumanMessage +from octop_harness.messages import extract_call_response +from octop_harness.teams.inbox import InboxMessage +from octop_harness.teams.processor import ReplyEvent +from octop_harness.teams.util import ( PeerCall, PeerSession, build_one_shot_request, derive_peer_thread_id, ) -from langchain_core.messages import AIMessage, HumanMessage from octop.i18n import tr from octop.infra.agents.teams.service import is_team_agent diff --git a/src/octop/infra/backend/__init__.py b/src/octop/infra/backend/__init__.py index 554b4fa9d3..f300b471e0 100644 --- a/src/octop/infra/backend/__init__.py +++ b/src/octop/infra/backend/__init__.py @@ -2,7 +2,7 @@ - :mod:`adapter` — ``storage_backends`` row → harness spec (no I/O) - :mod:`resolver` — agent config ``named`` / ``composite`` expansion -- :mod:`probe` — admin connectivity checks (delegates round-trip to harness-agent) +- :mod:`probe` — admin connectivity checks (delegates round-trip to octop-harness) """ from octop.infra.backend.adapter import row_to_backend_spec diff --git a/src/octop/infra/backend/adapter.py b/src/octop/infra/backend/adapter.py index 1b19d6b8d1..12dccf7feb 100644 --- a/src/octop/infra/backend/adapter.py +++ b/src/octop/infra/backend/adapter.py @@ -1,4 +1,4 @@ -"""Map Octop ``storage_backends`` rows to harness-agent backend specs (no I/O).""" +"""Map Octop ``storage_backends`` rows to octop-harness backend specs (no I/O).""" from __future__ import annotations diff --git a/src/octop/infra/backend/browse.py b/src/octop/infra/backend/browse.py index e8b6afda91..75c7f34d87 100644 --- a/src/octop/infra/backend/browse.py +++ b/src/octop/infra/backend/browse.py @@ -6,7 +6,7 @@ import tempfile from typing import Any -from harness_agent.backends import resolve_backend +from octop_harness.backends import resolve_backend from octop.infra.backend.adapter import row_to_backend_spec from octop.infra.db.repos.backends import BackendRow diff --git a/src/octop/infra/backend/probe.py b/src/octop/infra/backend/probe.py index 7442864e70..132dfad2c7 100644 --- a/src/octop/infra/backend/probe.py +++ b/src/octop/infra/backend/probe.py @@ -10,7 +10,7 @@ import uuid from typing import Any, cast -from harness_agent.backends.probe import probe_backend +from octop_harness.backends.probe import probe_backend from octop.infra.backend.adapter import row_to_backend_spec from octop.infra.backend.docker_spec import ( @@ -138,7 +138,7 @@ def _probe_docker(row: BackendRow) -> dict[str, Any]: return { "ok": False, "message": ( - "docker Python package not installed (pip install 'orcakit-harness-agent[docker]')" + "docker Python package not installed (pip install 'octop-harness[docker]')" ), } @@ -149,14 +149,12 @@ def _probe_docker(row: BackendRow) -> dict[str, Any]: return {"ok": False, "message": f"docker daemon unreachable: {exc}"} try: - from harness_agent.backends import resolve_backend - from harness_agent.backends.docker_sandbox import ensure_docker_image + from octop_harness.backends import resolve_backend + from octop_harness.backends.docker_sandbox import ensure_docker_image except ImportError: return { "ok": False, - "message": ( - "docker sandbox helpers unavailable (pip install 'orcakit-harness-agent[docker]')" - ), + "message": ("docker sandbox helpers unavailable (pip install 'octop-harness[docker]')"), } try: diff --git a/src/octop/infra/backend/resolver.py b/src/octop/infra/backend/resolver.py index fb53cefda6..e51fa26ac9 100644 --- a/src/octop/infra/backend/resolver.py +++ b/src/octop/infra/backend/resolver.py @@ -20,7 +20,7 @@ def default_agent_backend_spec(workspace_dir: Path) -> dict[str, Any]: the process current-drive root, which often differs from the drive hosting ``workspace_dir`` — scope the default to the agent workspace instead. """ - from harness_agent.backends import DEFAULT_BACKEND_SPEC # noqa: PLC0415 + from octop_harness.backends import DEFAULT_BACKEND_SPEC # noqa: PLC0415 if os.name == "nt": return { diff --git a/src/octop/infra/backup/workspace_archive.py b/src/octop/infra/backup/workspace_archive.py index eb6a0167c2..9bd320ec55 100644 --- a/src/octop/infra/backup/workspace_archive.py +++ b/src/octop/infra/backup/workspace_archive.py @@ -10,10 +10,10 @@ from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING, Any, Literal -from harness_agent.backends.utils import materialize_storage_path +from octop_harness.backends.utils import materialize_storage_path if TYPE_CHECKING: - from harness_agent.backends.workspace import BackendWorkspace + from octop_harness.backends.workspace import BackendWorkspace WorkspaceImportMode = Literal["merge", "replace"] diff --git a/src/octop/infra/browser/setup.py b/src/octop/infra/browser/setup.py index 810ceac6bb..a1daf4de9a 100644 --- a/src/octop/infra/browser/setup.py +++ b/src/octop/infra/browser/setup.py @@ -49,15 +49,15 @@ def _temp_scope_token(uid: int | None = None) -> str: def _runtime_dir_for_uid(uid: int | None = None) -> Path: token = _temp_scope_token(uid) if _is_linux(): - return Path(f"/tmp/runtime-harness-browser-{token}") - return Path(tempfile.gettempdir()) / f"runtime-harness-browser-{token}" + return Path(f"/tmp/runtime-octop-browser-{token}") + return Path(tempfile.gettempdir()) / f"runtime-octop-browser-{token}" def _relocated_profiles_root_for_uid(uid: int | None = None) -> Path: token = _temp_scope_token(uid) if _is_linux(): - return Path(f"/tmp/harness-browser-profiles-{token}") - return Path(tempfile.gettempdir()) / f"harness-browser-profiles-{token}" + return Path(f"/tmp/octop-browser-profiles-{token}") + return Path(tempfile.gettempdir()) / f"octop-browser-profiles-{token}" def ensure_chrome_runtime_env() -> Path: @@ -93,7 +93,7 @@ def resolve_browser_display() -> str | None: """Pick a usable X11 display for headed Chrome (virtual desktop or env). When Octop's Linux virtual desktop (Xvnc ``:99``) is running, inject - ``DISPLAY`` into the process env so harness-browser launches headed and + ``DISPLAY`` into the process env so octop-browser launches headed and the window appears on the remote desktop. Returns ``None`` when no live X11 socket exists. A stale ``$DISPLAY`` @@ -215,7 +215,7 @@ def ensure_profile_writable(profile_dir: Path) -> Path: Does not chmod/chown existing trees (that previously broke ``/`` and ``/tmp``). Strategy: probe → recreate empty → relocate under - ``/tmp/harness-browser-profiles-``. + ``/tmp/octop-browser-profiles-``. Profiles under ``/root/...`` are relocated proactively: host security agents (e.g. YunJing) often allow Python writes but deny the Chrome @@ -278,7 +278,7 @@ async def prepare_harness_profile_for_launch( force_recover: bool = False, profiles_root: Path | None = None, ) -> Path: - """Make a harness-browser profile safe to (re)launch Chrome against. + """Make an octop-browser profile safe to (re)launch Chrome against. - Forces a writable ``XDG_RUNTIME_DIR`` - Injects virtual-desktop ``DISPLAY`` when Xvnc is up @@ -355,7 +355,7 @@ def pkill_chrome_profile(profile_dir: Path) -> bool: def _profiles_root() -> Path: try: - from harness_browser.settings import settings as _settings # noqa: PLC0415 + from octop_browser.settings import settings as _settings # noqa: PLC0415 return Path(_settings.profiles_dir) except Exception: # noqa: BLE001 @@ -408,7 +408,7 @@ def chrome_source_for_path(chrome_path: str | None) -> str | None: continue # Also match when chromium_executable() reports the same path. try: - from harness_browser.install import chromium_executable # noqa: PLC0415 + from octop_browser.install import chromium_executable # noqa: PLC0415 pw = chromium_executable() if pw and Path(pw).resolve() == resolved: @@ -421,7 +421,7 @@ def chrome_source_for_path(chrome_path: str | None) -> str | None: async def _cdp_port_listening(port: int) -> bool: try: import aiohttp # noqa: PLC0415 - from harness_browser.settings import settings as _settings # noqa: PLC0415 + from octop_browser.settings import settings as _settings # noqa: PLC0415 host = _settings.cdp_host timeout = aiohttp.ClientTimeout(total=1) @@ -436,7 +436,7 @@ async def _cdp_port_listening(port: int) -> bool: def _profile_port(profile_name: str) -> int | None: try: - from harness_browser.profile import ProfileManager # noqa: PLC0415 + from octop_browser.profile import ProfileManager # noqa: PLC0415 pm = ProfileManager() profile = pm.get_or_create(profile_name) @@ -447,7 +447,7 @@ def _profile_port(profile_name: str) -> int | None: def _profile_data_dir(profile_name: str) -> Path: try: - from harness_browser.profile import ProfileManager # noqa: PLC0415 + from octop_browser.profile import ProfileManager # noqa: PLC0415 return Path(ProfileManager().get_or_create(profile_name).data_dir) except Exception: # noqa: BLE001 @@ -457,7 +457,7 @@ def _profile_data_dir(profile_name: str) -> Path: async def _close_harness_registry() -> int: closed = 0 try: - from harness_browser.tool_interface import _registry # noqa: PLC0415 + from octop_browser.tool_interface import _registry # noqa: PLC0415 except ImportError: return 0 @@ -473,7 +473,7 @@ async def uninstall_browser_stream(*, locale: str = "en") -> AsyncIterator[str]: """Remove Playwright-installed Chromium only (SSE). Does **not** touch the user's system Chrome/Chromium, and does **not** - delete ``~/.harness-browser`` profile data (login cookies etc.). + delete ``~/.octop/browser-profiles`` or leftover ``~/.harness-browser`` data. ``locale`` is reserved for future i18n of log lines. """ diff --git a/src/octop/infra/connectors/builder.py b/src/octop/infra/connectors/builder.py index 566bd784ab..600f5c7215 100644 --- a/src/octop/infra/connectors/builder.py +++ b/src/octop/infra/connectors/builder.py @@ -573,7 +573,7 @@ def inject_missing_gateway_tools( """Register gateway tools in-process when HTTP MCP load did not produce them.""" import logging - from harness_agent.mcp import mcp_tool_names + from octop_harness.mcp import mcp_tool_names from octop.infra.connectors.gateway import build_gateway_langchain_tools diff --git a/src/octop/infra/connectors/gateway/langchain.py b/src/octop/infra/connectors/gateway/langchain.py index f1fbac4d8b..0662c1efbe 100644 --- a/src/octop/infra/connectors/gateway/langchain.py +++ b/src/octop/infra/connectors/gateway/langchain.py @@ -5,7 +5,7 @@ import json from typing import Any -from harness_agent.mcp import mcp_args_model, sanitize_llm_tool_name +from octop_harness.mcp import mcp_args_model, sanitize_llm_tool_name from octop.infra.connectors.catalog import ConnectorCatalogEntry, is_inprocess_gateway from octop.infra.connectors.gateway.protocol import handle_mcp_request diff --git a/src/octop/infra/db/repos/sessions.py b/src/octop/infra/db/repos/sessions.py index afa5b2355d..b56bdabe9c 100644 --- a/src/octop/infra/db/repos/sessions.py +++ b/src/octop/infra/db/repos/sessions.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import Any -from harness_gateway.models import ChannelSubject +from octop_gateway.models import ChannelSubject from octop.infra.db.pool import DatabasePool from octop.infra.db.repos._base import DbRow, now_ts, sql_in_placeholders diff --git a/src/octop/infra/gateway/channels/qr_bind.py b/src/octop/infra/gateway/channels/qr_bind.py index e49e213516..88bb571e29 100644 --- a/src/octop/infra/gateway/channels/qr_bind.py +++ b/src/octop/infra/gateway/channels/qr_bind.py @@ -59,9 +59,9 @@ async def wecom_qr_poll(scode: str) -> dict[str, Any]: async def qq_qr_generate(session_id: str) -> dict[str, str]: """Create a QQ Bot QR task, replacing any pending task for this session.""" try: - from harness_gateway.channels.qq import QQBotQRLogin + from octop_gateway.channels.qq import QQBotQRLogin except ImportError as exc: - raise RuntimeError("QQ QR login requires a recent harness-gateway installation.") from exc + raise RuntimeError("QQ QR login requires a recent octop-gateway installation.") from exc previous = _qq_qr_sessions.pop(session_id, None) if previous is not None: @@ -101,10 +101,10 @@ async def qq_qr_poll(session_id: str, qrcode_token: str) -> dict[str, Any]: async def weixin_qr_generate() -> dict[str, str]: try: - from harness_gateway.channels.weixin.login_qr import WeixinQRLogin + from octop_gateway.channels.weixin.login_qr import WeixinQRLogin except ImportError as exc: raise RuntimeError( - "WeChat QR login requires harness-gateway with weixin channel support." + "WeChat QR login requires octop-gateway with weixin channel support." ) from exc login = WeixinQRLogin() result = await login.fetch_qr_code() @@ -116,10 +116,10 @@ async def weixin_qr_generate() -> dict[str, str]: async def weixin_qr_poll(qrcode_token: str) -> dict[str, Any]: try: - from harness_gateway.channels.weixin.login_qr import WeixinQRLogin + from octop_gateway.channels.weixin.login_qr import WeixinQRLogin except ImportError as exc: raise RuntimeError( - "WeChat QR login requires harness-gateway with weixin channel support." + "WeChat QR login requires octop-gateway with weixin channel support." ) from exc login = WeixinQRLogin() try: diff --git a/src/octop/infra/gateway/cli/cli_channel.py b/src/octop/infra/gateway/cli/cli_channel.py index 0e7c183750..b13eced510 100644 --- a/src/octop/infra/gateway/cli/cli_channel.py +++ b/src/octop/infra/gateway/cli/cli_channel.py @@ -6,9 +6,9 @@ import logging from typing import Any -from harness_gateway.channel import BaseChannel, MessageProcessor -from harness_gateway.constraints import ChannelConstraints -from harness_gateway.models import ( +from octop_gateway.channel import BaseChannel, MessageProcessor +from octop_gateway.constraints import ChannelConstraints +from octop_gateway.models import ( AudioContent, ChannelSubject, ContentPart, diff --git a/src/octop/infra/gateway/cli/turn.py b/src/octop/infra/gateway/cli/turn.py index 7de711a442..2a225183d6 100644 --- a/src/octop/infra/gateway/cli/turn.py +++ b/src/octop/infra/gateway/cli/turn.py @@ -4,7 +4,7 @@ from typing import Any -from harness_gateway.models import ChannelSubject, InboundMessage, TextContent +from octop_gateway.models import ChannelSubject, InboundMessage, TextContent from octop.infra.errors import ErrorCode, OctopError from octop.infra.gateway.cli.cli_channel import CLI_CHANNEL_ID, CLI_CONNECTION_META diff --git a/src/octop/infra/gateway/gateway.py b/src/octop/infra/gateway/gateway.py index b9ae3381aa..685eaff7d8 100644 --- a/src/octop/infra/gateway/gateway.py +++ b/src/octop/infra/gateway/gateway.py @@ -10,10 +10,10 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any -from harness_gateway.channel import ChannelCredentialsError -from harness_gateway.channels import ChannelKind -from harness_gateway.manager import ChannelManager -from harness_gateway.models import ChannelSubject +from octop_gateway.channel import ChannelCredentialsError +from octop_gateway.channels import ChannelKind +from octop_gateway.manager import ChannelManager +from octop_gateway.models import ChannelSubject from octop.i18n import channel_probe_incomplete, channel_runtime_reason, tr from octop.infra.db.repos.channels import ChannelRow @@ -93,7 +93,7 @@ async def _probe_processor(_msg: Any) -> Any: class Gateway: """Global AI interaction entry point. - Owns the harness-gateway ChannelManager. Routes IM messages + Owns the octop-gateway ChannelManager. Routes IM messages by ``InboundMessage.tenant_id`` (== agent ULID) via GlobalProcessor. """ @@ -456,7 +456,7 @@ def _channel_id_for_session(session: SessionRow) -> str: return session.channel_id def _resolve_push_subject(self, session: SessionRow) -> ChannelSubject: - """Build ChannelSubject from session; IM routing enrichment is in harness-gateway.""" + """Build ChannelSubject from session; IM routing enrichment is in octop-gateway.""" subject = session.to_channel_subject() if not ThreadRegistry.is_virtual_channel(session.channel_type): return subject diff --git a/src/octop/infra/gateway/hitl/coordinator.py b/src/octop/infra/gateway/hitl/coordinator.py index cb67f1d76f..aab0777050 100644 --- a/src/octop/infra/gateway/hitl/coordinator.py +++ b/src/octop/infra/gateway/hitl/coordinator.py @@ -6,8 +6,8 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal -from harness_agent.slash import SlashCommand -from harness_gateway.models import MessageEvent +from octop_gateway.models import MessageEvent +from octop_harness.slash import SlashCommand from octop.i18n.domains.slash import tr from octop.infra.agents.security.hitl_session import HitlSessionPolicyStore diff --git a/src/octop/infra/gateway/media/__init__.py b/src/octop/infra/gateway/media/__init__.py index e49871ebdd..a43968a305 100644 --- a/src/octop/infra/gateway/media/__init__.py +++ b/src/octop/infra/gateway/media/__init__.py @@ -2,7 +2,7 @@ Three cohesive concerns, one per submodule: -- :mod:`.ingress` — ``AgentBackedMediaBackend``: the harness-gateway +- :mod:`.ingress` — ``AgentBackedMediaBackend``: the octop-gateway ``MediaBackend`` adapter that persists inbound IM attachments. - :mod:`.backend_files` — binary file I/O + dashboard media preview/URL resolution and host-path guards, all through ``agent.backend``. diff --git a/src/octop/infra/gateway/media/attachment_hints.py b/src/octop/infra/gateway/media/attachment_hints.py index 7f3978d080..b2b967b908 100644 --- a/src/octop/infra/gateway/media/attachment_hints.py +++ b/src/octop/infra/gateway/media/attachment_hints.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from harness_gateway.models import ( +from octop_gateway.models import ( AudioContent, ContentPart, FileContent, @@ -32,8 +32,8 @@ from octop.infra.utils.locale import Locale if TYPE_CHECKING: - from harness_agent.backends.workspace import BackendWorkspace - from harness_gateway.media import MediaBackend + from octop_gateway.media import MediaBackend + from octop_harness.backends.workspace import BackendWorkspace logger = logging.getLogger(__name__) diff --git a/src/octop/infra/gateway/media/backend_files.py b/src/octop/infra/gateway/media/backend_files.py index f26937ea97..ce42bfa00c 100644 --- a/src/octop/infra/gateway/media/backend_files.py +++ b/src/octop/infra/gateway/media/backend_files.py @@ -2,7 +2,7 @@ Dashboard / gateway code uses this module — **not** :class:`~octop.infra.gateway.media.ingress.AgentBackedMediaBackend`, -which is only the harness-gateway ``MediaBackend`` adapter for IM ingress. +which is only the octop-gateway ``MediaBackend`` adapter for IM ingress. Path rule for ``BackendWorkspace`` ---------------------------------- @@ -33,7 +33,7 @@ from octop.infra.utils.browser_media import legacy_harness_screenshots_dir if TYPE_CHECKING: - from harness_agent.backends.workspace import BackendWorkspace + from octop_harness.backends.workspace import BackendWorkspace logger = logging.getLogger(__name__) @@ -58,6 +58,15 @@ "c:/program files (x86)/", ) +# New default is ``~/.octop-browser``; keep ``~/.harness-browser`` as a deny +# source so leftover profiles cannot be downloaded after migration. +_DENIED_BROWSER_HOME_SEGMENTS = (".harness-browser", ".octop-browser") + + +def _contains_denied_browser_home(text: str) -> bool: + lowered = text.replace("\\", "/").lower() + return any(segment in lowered for segment in _DENIED_BROWSER_HOME_SEGMENTS) + def _normalize_host_path(raw: str) -> str: """Lowercase path with forward slashes for prefix checks.""" @@ -80,8 +89,7 @@ def is_blocked_host_download_path(raw: str) -> bool: if not text: return False - lowered = text.replace("\\", "/").lower() - if ".harness-browser" in lowered: + if _contains_denied_browser_home(text): return True if len(text) >= 2 and text[1] == ":": @@ -347,7 +355,7 @@ def is_allowed_host_download_abs_path(path: str, *, workspace: Path) -> bool: return False norm = str(resolved).replace("\\", "/").lower() - if ".harness-browser" in norm: + if _contains_denied_browser_home(norm): return False try: diff --git a/src/octop/infra/gateway/media/inbound_store.py b/src/octop/infra/gateway/media/inbound_store.py index 820dd4955d..b4441978eb 100644 --- a/src/octop/infra/gateway/media/inbound_store.py +++ b/src/octop/infra/gateway/media/inbound_store.py @@ -18,7 +18,7 @@ from octop.infra.gateway.media.constants import INBOUND_DIR if TYPE_CHECKING: - from harness_agent.backends.workspace import BackendWorkspace + from octop_harness.backends.workspace import BackendWorkspace MAX_INBOUND_BYTES = upload_mb_to_bytes(DEFAULT_MAX_UPLOAD_MB) diff --git a/src/octop/infra/gateway/media/ingress.py b/src/octop/infra/gateway/media/ingress.py index 22ec3251da..dc17d1771e 100644 --- a/src/octop/infra/gateway/media/ingress.py +++ b/src/octop/infra/gateway/media/ingress.py @@ -4,15 +4,15 @@ from pathlib import Path -from harness_agent.backends.workspace import BackendWorkspace -from harness_gateway.media import MediaBackend +from octop_gateway.media import MediaBackend +from octop_harness.backends.workspace import BackendWorkspace from octop.infra.gateway.media.constants import OUTBOUND_DIR from octop.infra.gateway.media.inbound_store import inbound_rel_path, validate_inbound_size class AgentBackedMediaBackend(MediaBackend): - """harness-gateway ``MediaBackend`` adapter backed by ``agent.workspace``.""" + """octop-gateway ``MediaBackend`` adapter backed by ``agent.workspace``.""" def __init__(self, workspace: BackendWorkspace, *, max_bytes: int | None = None) -> None: self._workspace = workspace diff --git a/src/octop/infra/gateway/media/tool_media.py b/src/octop/infra/gateway/media/tool_media.py index 54d0bb772e..f4201618a2 100644 --- a/src/octop/infra/gateway/media/tool_media.py +++ b/src/octop/infra/gateway/media/tool_media.py @@ -18,9 +18,9 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from harness_agent.backends.workspace import BackendWorkspace + from octop_harness.backends.workspace import BackendWorkspace -from harness_gateway.models import ( +from octop_gateway.models import ( AudioContent, ContentPart, FileContent, diff --git a/src/octop/infra/gateway/process/agent_resolve.py b/src/octop/infra/gateway/process/agent_resolve.py index d15fa2c6ba..0e13c995c3 100644 --- a/src/octop/infra/gateway/process/agent_resolve.py +++ b/src/octop/infra/gateway/process/agent_resolve.py @@ -5,7 +5,7 @@ import logging from typing import TYPE_CHECKING -from harness_agent.backends.workspace import BackendWorkspace +from octop_harness.backends.workspace import BackendWorkspace from octop.infra.gateway.media.ingress import AgentBackedMediaBackend @@ -31,7 +31,7 @@ def media_backend_for_agent( agent_manager: AgentManager, agent_id: str, ) -> AgentBackedMediaBackend | None: - """harness-gateway ``MediaBackend`` adapter for IM channel ingress.""" + """octop-gateway ``MediaBackend`` adapter for IM channel ingress.""" workspace = harness_workspace_for_agent(agent_manager, agent_id) if workspace is None: return None diff --git a/src/octop/infra/gateway/process/harness_request.py b/src/octop/infra/gateway/process/harness_request.py index 7a4776e2d8..368a2da85b 100644 --- a/src/octop/infra/gateway/process/harness_request.py +++ b/src/octop/infra/gateway/process/harness_request.py @@ -1,12 +1,12 @@ -"""Assemble harness-agent stream requests and multimodal user content.""" +"""Assemble octop-harness stream requests and multimodal user content.""" from __future__ import annotations from collections.abc import Sequence from typing import Any -from harness_gateway.media import MediaBackend -from harness_gateway.models import ContentPart, ImageContent, InboundMessage, TextContent +from octop_gateway.media import MediaBackend +from octop_gateway.models import ContentPart, ImageContent, InboundMessage, TextContent from octop.i18n.domains.attachment import attachment_empty_image, attachment_empty_message from octop.infra.gateway.media.attachment_hints import ( diff --git a/src/octop/infra/gateway/process/message_keys.py b/src/octop/infra/gateway/process/message_keys.py index de4f55b566..87ca702c6d 100644 --- a/src/octop/infra/gateway/process/message_keys.py +++ b/src/octop/infra/gateway/process/message_keys.py @@ -1,17 +1,17 @@ -"""Derive session keys and user ids from harness-gateway InboundMessage.""" +"""Derive session keys and user ids from octop-gateway InboundMessage.""" from __future__ import annotations from typing import Any -from harness_gateway.models import ImageContent, InboundMessage +from octop_gateway.models import ImageContent, InboundMessage from octop.infra.gateway.threads import ThreadRegistry # Persisted on HumanMessage.additional_kwargs for dashboard history UI. COMPOSER_CTX_KEY = "octop_composer_context" INBOUND_ATTACHMENTS_KEY = "octop_inbound_attachments" -# Must match harness_agent.messages.CHECKPOINT_TS_KEY (epoch-ms). +# Must match octop_harness.messages.CHECKPOINT_TS_KEY (epoch-ms). CHECKPOINT_TS_KEY = "checkpoint_ts" # Persisted on AIMessage.additional_kwargs when a stream fails mid-turn. STREAM_ERROR_FLAG = "octop_stream_error" @@ -56,8 +56,8 @@ def build_composer_context( try: - from harness_gateway.push_routing import EPHEMERAL_PUSH_META -except ImportError: # pragma: no cover - older harness-gateway + from octop_gateway.push_routing import EPHEMERAL_PUSH_META +except ImportError: # pragma: no cover - older octop-gateway EPHEMERAL_PUSH_META = frozenset( { "msg_id", diff --git a/src/octop/infra/gateway/process/processor.py b/src/octop/infra/gateway/process/processor.py index 480421fc04..211818b274 100644 --- a/src/octop/infra/gateway/process/processor.py +++ b/src/octop/infra/gateway/process/processor.py @@ -1,4 +1,4 @@ -"""GlobalProcessor — harness-gateway MessageProcessor; team room lives on TeamManager.""" +"""GlobalProcessor — octop-gateway MessageProcessor; team room lives on TeamManager.""" from __future__ import annotations @@ -8,17 +8,17 @@ from types import SimpleNamespace from typing import TYPE_CHECKING, Any, Literal -from harness_agent.slash import SlashSink -from harness_agent.teams.inbox import InboxMessage -from harness_agent.teams.processor import ReplyEvent -from harness_agent.teams.util import PeerCall, PeerSession -from harness_gateway.models import ( +from langchain_core.messages import AIMessage, BaseMessage, HumanMessage +from octop_gateway.models import ( InboundMessage, MessageEvent, MessageEventType, TextContent, ) -from langchain_core.messages import AIMessage, BaseMessage, HumanMessage +from octop_harness.slash import SlashSink +from octop_harness.teams.inbox import InboxMessage +from octop_harness.teams.processor import ReplyEvent +from octop_harness.teams.util import PeerCall, PeerSession from octop.i18n.domains.stream import format_stream_error from octop.infra.agents.profile import parse_config_json @@ -887,7 +887,7 @@ async def iter_turn_chunks(self, msg: InboundMessage) -> AsyncIterator[dict[str, For transports that consume the dashboard chunk protocol (``token``, ``tool_result``, ``attachment``, ``done``, …) without going through - harness-gateway :class:`MessageEvent` batching. + octop-gateway :class:`MessageEvent` batching. IM channels use :meth:`__call__` → ``project_stream`` → ``MessageEvent`` (e.g. DingTalk ``BaseChannel.handle_inbound``). diff --git a/src/octop/infra/gateway/process/response_mode.py b/src/octop/infra/gateway/process/response_mode.py index 2fe59515fc..190ae95fb8 100644 --- a/src/octop/infra/gateway/process/response_mode.py +++ b/src/octop/infra/gateway/process/response_mode.py @@ -5,8 +5,8 @@ from collections.abc import AsyncIterator from typing import Any, Literal -from harness_gateway.channel import MessageProcessor -from harness_gateway.models import ( +from octop_gateway.channel import MessageProcessor +from octop_gateway.models import ( ContentPart, InboundMessage, MessageEvent, diff --git a/src/octop/infra/gateway/process/stream_project.py b/src/octop/infra/gateway/process/stream_project.py index fe90225bdf..70b6400350 100644 --- a/src/octop/infra/gateway/process/stream_project.py +++ b/src/octop/infra/gateway/process/stream_project.py @@ -1,4 +1,4 @@ -"""Project harness stream chunks into harness-gateway MessageEvent objects.""" +"""Project harness stream chunks into octop-gateway MessageEvent objects.""" from __future__ import annotations @@ -6,8 +6,8 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any -from harness_gateway.media import MediaBackend -from harness_gateway.models import MessageEvent +from octop_gateway.media import MediaBackend +from octop_gateway.models import MessageEvent from octop.i18n import channel_tool_hint_end, channel_tool_hint_start, tool_display_name from octop.infra.gateway.hitl.format import format_hitl_card diff --git a/src/octop/infra/gateway/slash/__init__.py b/src/octop/infra/gateway/slash/__init__.py index 0437a69d66..61a70a1113 100644 --- a/src/octop/infra/gateway/slash/__init__.py +++ b/src/octop/infra/gateway/slash/__init__.py @@ -1,6 +1,6 @@ """Slash command parsing and dispatch.""" -from harness_agent.slash import BufferSink, SlashCommand, SlashSink +from octop_harness.slash import BufferSink, SlashCommand, SlashSink from octop.infra.gateway.slash.ctx import SlashCtx, build_slash_ctx from octop.infra.gateway.slash.dispatcher import SlashDispatcher, build_default_dispatcher diff --git a/src/octop/infra/gateway/slash/dispatcher.py b/src/octop/infra/gateway/slash/dispatcher.py index 3820a57911..52ce4350e6 100644 --- a/src/octop/infra/gateway/slash/dispatcher.py +++ b/src/octop/infra/gateway/slash/dispatcher.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -from harness_agent.slash import ( +from octop_harness.slash import ( RuntimeSlashDispatcher, SlashCommand, SlashSink, diff --git a/src/octop/infra/gateway/slash/handlers/composite.py b/src/octop/infra/gateway/slash/handlers/composite.py index 4834d77868..211862fff9 100644 --- a/src/octop/infra/gateway/slash/handlers/composite.py +++ b/src/octop/infra/gateway/slash/handlers/composite.py @@ -7,8 +7,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from harness_agent.compaction import display_offload_path -from harness_agent.slash import SlashCommand, SlashSink, thread_message_count +from octop_harness.compaction import display_offload_path +from octop_harness.slash import SlashCommand, SlashSink, thread_message_count from octop.i18n.domains.agents import agent_error_message from octop.i18n.domains.slash import localized_rows, tr diff --git a/src/octop/infra/gateway/slash/handlers/hitl.py b/src/octop/infra/gateway/slash/handlers/hitl.py index 6f2a4cf5b3..888b683723 100644 --- a/src/octop/infra/gateway/slash/handlers/hitl.py +++ b/src/octop/infra/gateway/slash/handlers/hitl.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -from harness_agent.slash import SlashCommand, SlashSink +from octop_harness.slash import SlashCommand, SlashSink from octop.i18n.domains.slash import tr from octop.infra.gateway.slash.ctx import SlashCtx, lang_of diff --git a/src/octop/infra/gateway/slash/handlers/memory.py b/src/octop/infra/gateway/slash/handlers/memory.py index 0efcc44055..cb5975424a 100644 --- a/src/octop/infra/gateway/slash/handlers/memory.py +++ b/src/octop/infra/gateway/slash/handlers/memory.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from harness_agent.slash import SlashCommand, SlashSink +from octop_harness.slash import SlashCommand, SlashSink from octop.i18n import tr as full_tr from octop.i18n.domains.slash import tr diff --git a/src/octop/infra/gateway/slash/handlers/platform.py b/src/octop/infra/gateway/slash/handlers/platform.py index 5a22ef40e8..56dab852c0 100644 --- a/src/octop/infra/gateway/slash/handlers/platform.py +++ b/src/octop/infra/gateway/slash/handlers/platform.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -from harness_agent.slash import SlashCommand, SlashSink +from octop_harness.slash import SlashCommand, SlashSink from octop.i18n.domains.slash import localized_rows, tr from octop.infra.errors import ErrorCode, OctopError diff --git a/src/octop/infra/gateway/slash/handlers/session.py b/src/octop/infra/gateway/slash/handlers/session.py index 2009f4a421..14a58fb418 100644 --- a/src/octop/infra/gateway/slash/handlers/session.py +++ b/src/octop/infra/gateway/slash/handlers/session.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -from harness_agent.slash import SlashCommand, SlashSink +from octop_harness.slash import SlashCommand, SlashSink from octop.i18n.domains.slash import tr from octop.infra.db.repos.threads import clip_thread_title diff --git a/src/octop/infra/gateway/slash/parser.py b/src/octop/infra/gateway/slash/parser.py index ad5df3b6a9..2439e5e1b4 100644 --- a/src/octop/infra/gateway/slash/parser.py +++ b/src/octop/infra/gateway/slash/parser.py @@ -4,8 +4,8 @@ import re -from harness_agent.slash import SlashCommand -from harness_agent.slash import parse_slash as _parse_slash +from octop_harness.slash import SlashCommand +from octop_harness.slash import parse_slash as _parse_slash # The harness parser stops the command name at the first non-word character, so # ``/root/ddd`` would dispatch as ``/root``. Require the name to end at diff --git a/src/octop/infra/gateway/slash/runner.py b/src/octop/infra/gateway/slash/runner.py index cbc336af11..75da07c260 100644 --- a/src/octop/infra/gateway/slash/runner.py +++ b/src/octop/infra/gateway/slash/runner.py @@ -2,7 +2,7 @@ from __future__ import annotations -from harness_agent.slash import BufferSink, SlashCommand, SlashSink +from octop_harness.slash import BufferSink, SlashCommand, SlashSink from octop.infra.gateway.slash.ctx import SlashCtx from octop.infra.gateway.slash.dispatcher import SlashDispatcher diff --git a/src/octop/infra/gateway/slash/runtime_bridge.py b/src/octop/infra/gateway/slash/runtime_bridge.py index 5d8d13a6dc..40fff6eb02 100644 --- a/src/octop/infra/gateway/slash/runtime_bridge.py +++ b/src/octop/infra/gateway/slash/runtime_bridge.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any -from harness_agent.slash import RuntimeSlashCtx, SlashCommand +from octop_harness.slash import RuntimeSlashCtx, SlashCommand from octop.infra.gateway.slash.ctx import SlashCtx, ensure_thread_id diff --git a/src/octop/infra/gateway/slash/types.py b/src/octop/infra/gateway/slash/types.py index 6c9d2c7ee3..671b4ad8e5 100644 --- a/src/octop/infra/gateway/slash/types.py +++ b/src/octop/infra/gateway/slash/types.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from harness_agent.slash import SlashCommand, SlashSink + from octop_harness.slash import SlashCommand, SlashSink from octop.infra.gateway.slash.ctx import SlashCtx from octop.infra.gateway.slash.dispatcher import SlashDispatcher diff --git a/src/octop/infra/gateway/ws/ws_channel.py b/src/octop/infra/gateway/ws/ws_channel.py index 50ed02cf20..f09604d9c3 100644 --- a/src/octop/infra/gateway/ws/ws_channel.py +++ b/src/octop/infra/gateway/ws/ws_channel.py @@ -6,9 +6,9 @@ import logging from typing import Any -from harness_gateway.channel import BaseChannel, MessageProcessor -from harness_gateway.constraints import ChannelConstraints -from harness_gateway.models import ( +from octop_gateway.channel import BaseChannel, MessageProcessor +from octop_gateway.constraints import ChannelConstraints +from octop_gateway.models import ( AudioContent, ChannelSubject, ContentPart, diff --git a/src/octop/infra/history/trajectory/turn_context.py b/src/octop/infra/history/trajectory/turn_context.py index 93984afa44..b98565de76 100644 --- a/src/octop/infra/history/trajectory/turn_context.py +++ b/src/octop/infra/history/trajectory/turn_context.py @@ -6,7 +6,7 @@ from collections.abc import Sequence from typing import Any -from harness_agent.backends.workspace import DEFAULT_MEMORY_FILES +from octop_harness.backends.workspace import DEFAULT_MEMORY_FILES # Mirror harness ``SkillFilterMiddleware``: memory files are injected via # ``BackendWorkspace.memory_paths(DEFAULT_MEMORY_FILES)``. diff --git a/src/octop/infra/proactive/picker.py b/src/octop/infra/proactive/picker.py index c0ef9e0ce1..4b6e693fc7 100644 --- a/src/octop/infra/proactive/picker.py +++ b/src/octop/infra/proactive/picker.py @@ -19,7 +19,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from harness_memory.types import Episode + from octop_memory.types import Episode logger = logging.getLogger(__name__) diff --git a/src/octop/infra/setup/self_update.py b/src/octop/infra/setup/self_update.py index 6b83199d81..712cf39e2b 100644 --- a/src/octop/infra/setup/self_update.py +++ b/src/octop/infra/setup/self_update.py @@ -583,7 +583,7 @@ def _run_fpk_upgrade( 安装到该打包目录本身,重启服务后即加载新版,升级真正生效。 与普通部署不同,FPK 首次在线升级需要从零解析并下载完整依赖树 - (octop 依赖 orcakit-harness-agent 等大包),故超时显著放宽;且某镜像 + (octop 依赖 octop-harness 等大包),故超时显著放宽;且某镜像 可能滞后(装到同版本旧版),此时继续尝试下一个镜像,最后以 pypi.org 兜底,避免「镜像有货但版本不新」导致升级假成功。 """ @@ -615,7 +615,7 @@ def _run_install(cmd: list[str], label: str) -> tuple[int | None, str]: return result.returncode, snippet python_exe = sys.executable - installer = detect_installer() # uv 优先:pip 对 orcakit-harness-agent[all] 依赖树解析会卡死 + installer = detect_installer() # uv 优先:pip 对 octop-harness[all] 依赖树解析会卡死 def _build_cmd(index_url: str = "") -> list[str]: requirement = package_requirement(version) diff --git a/src/octop/infra/skills/presentation.py b/src/octop/infra/skills/presentation.py index 75ef8e1ca6..450d02af60 100644 --- a/src/octop/infra/skills/presentation.py +++ b/src/octop/infra/skills/presentation.py @@ -8,7 +8,7 @@ from octop.infra.utils.locale import Locale -# Keep in sync with harness_agent.skills.catalog._summary_dict. +# Keep in sync with octop_harness.skills.catalog._summary_dict. _EXTENSION_NAMESPACES = ("octop", "harness", "lightclaw", "orca", "openclaw") diff --git a/src/octop/infra/utils/browser_media.py b/src/octop/infra/utils/browser_media.py index 832340f1df..05d4c7c0ca 100644 --- a/src/octop/infra/utils/browser_media.py +++ b/src/octop/infra/utils/browser_media.py @@ -1,4 +1,4 @@ -"""harness-browser media paths aligned with IM ``outbound/`` layout.""" +"""octop-browser media paths aligned with IM ``outbound/`` layout.""" from __future__ import annotations @@ -32,7 +32,7 @@ def parse_octop_user_id(raw: object) -> int | None: def user_browser_profile(user_id: int) -> str: - """Stable harness-browser profile name for one Octop user. + """Stable octop-browser profile name for one Octop user. Dashboard and CLI turns use the logged-in user. IM turns use the agent owner (the same id already stored on the thread). A leftover on-disk @@ -89,7 +89,7 @@ def octop_browser_profiles_dir(paths: PathLayout | None = None) -> Path: Prefer this directory over ``~/.harness-browser/profiles`` or system ``/tmp``. - When the Octop dir is empty and a legacy harness-browser profiles tree + When the Octop dir is empty and a legacy ``~/.harness-browser`` profiles tree exists, contents are moved once so login cookies survive the cutover. """ if paths is None: @@ -103,7 +103,7 @@ def octop_browser_profiles_dir(paths: PathLayout | None = None) -> Path: def configure_browser_screenshots_dir(screenshots_dir: Path) -> None: - """Point harness-browser screenshot actions at an agent workspace directory.""" + """Point octop-browser screenshot actions at an agent workspace directory.""" resolved = screenshots_dir.resolve() resolved.mkdir(parents=True, exist_ok=True) os.environ["BROWSER_USE_SCREENSHOTS_DIR"] = str(resolved) @@ -111,18 +111,18 @@ def configure_browser_screenshots_dir(screenshots_dir: Path) -> None: def configure_browser_profiles_dir(profiles_dir: Path | None = None) -> Path: - """Point harness-browser at a profiles directory; return the resolved root. + """Point octop-browser at a profiles directory; return the resolved root. When *profiles_dir* is omitted, uses :func:`octop_browser_profiles_dir` (including legacy migration). Also updates ``BROWSER_USE_PROFILES_DIR`` - and in-process harness-browser settings. + and in-process octop-browser settings. """ root = Path(profiles_dir) if profiles_dir is not None else octop_browser_profiles_dir() root.mkdir(parents=True, exist_ok=True) resolved = root.resolve() os.environ["BROWSER_USE_PROFILES_DIR"] = str(resolved) with contextlib.suppress(Exception): - from harness_browser.settings import settings as hb_settings # noqa: PLC0415 + from octop_browser.settings import settings as hb_settings # noqa: PLC0415 hb_settings.profiles_dir = resolved logger.debug("BROWSER_USE_PROFILES_DIR=%s", resolved) @@ -130,11 +130,11 @@ def configure_browser_profiles_dir(profiles_dir: Path | None = None) -> Path: def configure_browser_idle_timeout(timeout_minutes: int) -> None: - """Apply Octop's browser idle policy to harness-browser.""" + """Apply Octop's browser idle policy to octop-browser.""" timeout = max(int(timeout_minutes), 0) os.environ["BROWSER_USE_IDLE_TIMEOUT_MINUTES"] = str(timeout) with contextlib.suppress(Exception): - from harness_browser.settings import settings as hb_settings # noqa: PLC0415 + from octop_browser.settings import settings as hb_settings # noqa: PLC0415 runtime_settings: Any = hb_settings runtime_settings.idle_timeout_minutes = float(timeout) @@ -142,12 +142,12 @@ def configure_browser_idle_timeout(timeout_minutes: int) -> None: def harness_settings_for_screenshots_dir(screenshots_dir: Path) -> Any | None: - """Build :class:`HarnessSettings` when harness-browser is installed.""" + """Build :class:`OctopSettings` when octop-browser is installed.""" try: - from harness_browser.settings import HarnessSettings + from octop_browser.settings import OctopSettings except ImportError: return None - return HarnessSettings(screenshots_dir=screenshots_dir.resolve()) + return OctopSettings(screenshots_dir=screenshots_dir.resolve()) def legacy_harness_screenshots_dir() -> Path: diff --git a/tests/fixtures/plugins/echo-tool/main.py b/tests/fixtures/plugins/echo-tool/main.py index c0ffc639b4..e692693f56 100644 --- a/tests/fixtures/plugins/echo-tool/main.py +++ b/tests/fixtures/plugins/echo-tool/main.py @@ -4,7 +4,7 @@ import json -from harness_agent.plugins import PluginContext, get_tool_config +from octop_harness.plugins import PluginContext, get_tool_config async def echo_message(message: str) -> str: diff --git a/tests/integration/test_browser_api.py b/tests/integration/test_browser_api.py index f42da9992e..a4fa7ed2a5 100644 --- a/tests/integration/test_browser_api.py +++ b/tests/integration/test_browser_api.py @@ -60,12 +60,12 @@ def test_probe_env_harness_browser_without_chromium() -> None: from octop.api.routers.browser import env as br try: - import harness_browser # noqa: F401 + import octop_browser # noqa: F401 except ImportError: return with patch( - "harness_browser.cdp.launcher.find_chrome", + "octop_browser.cdp.launcher.find_chrome", return_value=None, ): out = br._probe_env() @@ -79,13 +79,13 @@ def test_probe_env_accepts_system_chrome() -> None: from octop.api.routers.browser import env as br try: - import harness_browser # noqa: F401 + import octop_browser # noqa: F401 except ImportError: return with ( patch( - "harness_browser.cdp.launcher.find_chrome", + "octop_browser.cdp.launcher.find_chrome", return_value="/usr/bin/google-chrome", ), patch( @@ -209,7 +209,7 @@ async def test_shutdown_ignores_client_profile(env: Any) -> None: c, _srv, auth = env result = SimpleNamespace(success=True, error=None) with patch( - "harness_browser.tool_interface.browser_tool", + "octop_browser.tool_interface.browser_tool", new=AsyncMock(return_value=result), ) as tool: r = await c.post("/api/browser/shutdown?profile=work", headers=auth) @@ -233,7 +233,7 @@ async def fake_stream(): yield {"done": True, "success": True} with patch( - "harness_browser.install_chromium_stream", + "octop_browser.install_chromium_stream", side_effect=lambda: fake_stream(), ): r = await c.post("/api/browser/install", headers=auth) diff --git a/tests/integration/test_browser_record_replay_api.py b/tests/integration/test_browser_record_replay_api.py index 71dd7d6e1e..1a89e6541c 100644 --- a/tests/integration/test_browser_record_replay_api.py +++ b/tests/integration/test_browser_record_replay_api.py @@ -112,7 +112,7 @@ async def test_record_replay_stop_generates_steps(env: Any) -> None: owned = SimpleNamespace(read_manifest=lambda _rid: SimpleNamespace(profile="user-1")) with ( - patch("harness_browser.record.store.RecordingStore", return_value=owned), + patch("octop_browser.record.store.RecordingStore", return_value=owned), patch( "octop.api.routers.browser.record_replay.send_record_request", new=AsyncMock( @@ -175,7 +175,7 @@ async def test_record_replay_replay_runs_runner(env: Any) -> None: with ( patch( - "harness_browser.record.store.RecordingStore", + "octop_browser.record.store.RecordingStore", return_value=SimpleNamespace( read_manifest=lambda _rid: SimpleNamespace(profile="user-1") ), @@ -223,7 +223,7 @@ async def test_record_skill_content_hides_other_user_recording(env: Any) -> None store = SimpleNamespace( read_manifest=lambda _rid: SimpleNamespace(profile="user-99"), ) - with patch("harness_browser.record.store.RecordingStore", return_value=store): + with patch("octop_browser.record.store.RecordingStore", return_value=store): r = await client.post( "/api/browser/record-replay/skill-content", headers=auth, diff --git a/tests/integration/test_bwrap_jail.py b/tests/integration/test_bwrap_jail.py index 89c460d523..3882624150 100644 --- a/tests/integration/test_bwrap_jail.py +++ b/tests/integration/test_bwrap_jail.py @@ -4,7 +4,7 @@ non-host ``root_dir`` + ``bwrap`` are available (factory routes before any backend I/O). Otherwise ``HarnessLocalShellBackend`` runs on the host; with ``virtual_mode`` and a scoped ``root_dir`` it still rewrites virtual absolute -paths in ``execute`` onto that root (harness-agent >= 1.0). +paths in ``execute`` onto that root (octop-harness >= 1.0). Cross-platform cases run on Windows, macOS, and Linux. POSIX-shell and real ``bwrap`` jail cases are skipped where the host cannot run them (see markers). @@ -19,8 +19,8 @@ from unittest.mock import patch from deepagents.backends.protocol import ExecuteResponse -from harness_agent.backends import resolve_backend -from harness_agent.backends.bwrap_shell import ( +from octop_harness.backends import resolve_backend +from octop_harness.backends.bwrap_shell import ( BubbledLocalShellBackend, HarnessLocalShellBackend, can_use_bubbled_shell, @@ -58,7 +58,7 @@ def test_resolve_backend_routes_bubbled_only_when_jail_available(tmp_path: Path) spec, scoped, workspace = _scoped_spec(tmp_path) with patch( - "harness_agent.backends.bwrap_shell.resolve_bubbled_bwrap", + "octop_harness.backends.bwrap_shell.resolve_bubbled_bwrap", return_value=_BWRAP, ): bubbled = resolve_backend(spec, workspace_dir=workspace) @@ -67,7 +67,7 @@ def test_resolve_backend_routes_bubbled_only_when_jail_available(tmp_path: Path) assert Path(bubbled.cwd).resolve() == scoped.resolve() with patch( - "harness_agent.backends.bwrap_shell.resolve_bubbled_bwrap", + "octop_harness.backends.bwrap_shell.resolve_bubbled_bwrap", return_value=None, ): plain = resolve_backend(spec, workspace_dir=workspace) @@ -90,7 +90,7 @@ def test_write_virtual_path_lands_under_scoped_root(tmp_path: Path) -> None: """Filesystem virtual ``/out/…`` maps under scoped ``root_dir`` on every OS.""" spec, scoped, workspace = _scoped_spec(tmp_path) with patch( - "harness_agent.backends.bwrap_shell.resolve_bubbled_bwrap", + "octop_harness.backends.bwrap_shell.resolve_bubbled_bwrap", return_value=None, ): backend = resolve_backend(spec, workspace_dir=workspace) @@ -102,7 +102,7 @@ def test_write_virtual_path_lands_under_scoped_root(tmp_path: Path) -> None: def test_read_virtual_path_from_scoped_root(tmp_path: Path) -> None: spec, scoped, workspace = _scoped_spec(tmp_path) with patch( - "harness_agent.backends.bwrap_shell.resolve_bubbled_bwrap", + "octop_harness.backends.bwrap_shell.resolve_bubbled_bwrap", return_value=None, ): backend = resolve_backend(spec, workspace_dir=workspace) @@ -119,7 +119,7 @@ def test_read_virtual_path_from_scoped_root(tmp_path: Path) -> None: def test_execute_without_jail_rewrites_virtual_command_paths(tmp_path: Path) -> None: """Non-jail scoped shell still maps virtual absolute paths onto ``root_dir``. - harness-agent 1.0 no longer delegates ``execute`` to deepagents + octop-harness 1.0 no longer delegates ``execute`` to deepagents ``LocalShellBackend.execute``; it rewrites then calls ``_execute_on_host``. Env mapping is stubbed so inaccessible ``PATH`` entries (common on CI) cannot raise ``PermissionError`` from ``Path.exists``. @@ -127,7 +127,7 @@ def test_execute_without_jail_rewrites_virtual_command_paths(tmp_path: Path) -> spec, scoped, workspace = _scoped_spec(tmp_path) (scoped / "out").mkdir() with patch( - "harness_agent.backends.bwrap_shell.resolve_bubbled_bwrap", + "octop_harness.backends.bwrap_shell.resolve_bubbled_bwrap", return_value=None, ): backend = resolve_backend(spec, workspace_dir=workspace) @@ -141,7 +141,7 @@ def test_execute_without_jail_rewrites_virtual_command_paths(tmp_path: Path) -> return_value=ExecuteResponse(output="ok", exit_code=0, truncated=False), ) as host_exec, patch( - "harness_agent.backends.local_shell.map_virtual_paths_in_env", + "octop_harness.backends.local_shell.map_virtual_paths_in_env", side_effect=lambda env, *_a, **_k: env, ), ): diff --git a/tests/integration/test_memory_api.py b/tests/integration/test_memory_api.py index 56530c3034..de9de97188 100644 --- a/tests/integration/test_memory_api.py +++ b/tests/integration/test_memory_api.py @@ -3,10 +3,10 @@ End-to-end smoke: build a real OctopServer with a main agent, seed a small graph (entity / candidate / atom / episode / journal) directly into the agent memory SQLite (``.octop/memory.sqlite`` for new agents) -via ``harness_memory.Memory``, then drive the FastAPI router and assert +via ``octop_memory.Memory``, then drive the FastAPI router and assert the JSON shapes. -We bypass the harness-agent middleware (``capture`` / ``extract``) +We bypass the octop-harness middleware (``capture`` / ``extract``) because the dashboard surface is purely read-side; this keeps the test fast and deterministic without an LLM. """ @@ -18,13 +18,13 @@ import pytest -# `harness_memory` is an optional dependency (lazy-imported by the app); the +# `octop_memory` is an optional dependency (lazy-imported by the app); the # real package is not part of the base install, so skip these integration # tests when it is unavailable rather than failing the whole suite. The app # reaches memory through the JSON-RPC bridge, so gate on that exact import -# target (the old ``harness_memory.lightclaw`` name no longer exists and made +# target (the old ``octop_memory.lightclaw`` name no longer exists and made # this whole file silently skip). -pytest.importorskip("harness_memory.adapters.bridge.handlers") +pytest.importorskip("octop_memory.adapters.bridge.handlers") from octop.api.common.memory_client import ( invalidate_cached_memory, @@ -39,8 +39,8 @@ def _now() -> datetime: def _seed_memory(srv: Any, agent_id: str) -> None: """Populate the agent's memory.sqlite with one row per layer.""" - from harness_memory.core import Memory # noqa: PLC0415 - from harness_memory.types import ( # noqa: PLC0415 + from octop_memory.core import Memory # noqa: PLC0415 + from octop_memory.types import ( # noqa: PLC0415 AtomCard, Candidate, Entity, diff --git a/tests/integration/test_postgresql_checkpoint_history.py b/tests/integration/test_postgresql_checkpoint_history.py index adc752296b..5bd733b12d 100644 --- a/tests/integration/test_postgresql_checkpoint_history.py +++ b/tests/integration/test_postgresql_checkpoint_history.py @@ -18,8 +18,8 @@ async def test_postgres_checkpoint_history_uses_graph_reader( monkeypatch: pytest.MonkeyPatch, ) -> None: - from harness_memory import Memory from langgraph.graph import END, START, MessagesState, StateGraph + from octop_memory import Memory from octop.api.routers.chat.serialize import ( _isolated_sqlite_checkpoint_messages, diff --git a/tests/live/conftest.py b/tests/live/conftest.py index 28104d9ce0..298478d8ce 100644 --- a/tests/live/conftest.py +++ b/tests/live/conftest.py @@ -114,7 +114,7 @@ async def live_agent_manager( ) # Real HarnessAgentManager starts memory GC on a daemon thread. Live # tests create/delete many agents quickly; closing SQLite while GC is - # in ``list_candidates`` segfaults (see AgentManager._quiesce_harness_memory). + # in ``list_candidates`` segfaults (see AgentManager._quiesce_octop_memory). # These tests do not exercise memory — keep it off. _create = manager.create diff --git a/tests/live/test_channel_probe.py b/tests/live/test_channel_probe.py index 56067052da..bd4e76604c 100644 --- a/tests/live/test_channel_probe.py +++ b/tests/live/test_channel_probe.py @@ -17,9 +17,9 @@ import aiohttp import pytest -from harness_gateway.channels.weixin.api import WeixinAPIClient -from harness_gateway.channels.weixin.types import WeixinAPIError -from harness_gateway.manager import ChannelManager +from octop_gateway.channels.weixin.api import WeixinAPIClient +from octop_gateway.channels.weixin.types import WeixinAPIError +from octop_gateway.manager import ChannelManager from tests.support.secrets import optional_env, require_env pytestmark = pytest.mark.live diff --git a/tests/support/fakes.py b/tests/support/fakes.py index 9277d82381..a13bccf0c9 100644 --- a/tests/support/fakes.py +++ b/tests/support/fakes.py @@ -17,9 +17,9 @@ LsResult, ReadResult, ) -from harness_agent.backends.workspace import BackendWorkspace -from harness_gateway.channel import BaseChannel -from harness_gateway.models import InboundMessage, MessageEvent +from octop_gateway.channel import BaseChannel +from octop_gateway.models import InboundMessage, MessageEvent +from octop_harness.backends.workspace import BackendWorkspace def fake_bin_path(name: str) -> str: @@ -32,7 +32,7 @@ def fake_bin_path(name: str) -> str: class FakeHarnessAgent: - """Minimal stand-in for ``harness_agent.HarnessAgent``. + """Minimal stand-in for ``octop_harness.HarnessAgent``. Yields a programmable list of chunk dicts, then ``state_snapshot``. The ``request`` argument is captured into ``last_request`` for @@ -199,18 +199,18 @@ def set_skills_disabled( self, disabled: set[str] | frozenset[str] | list[str] | None, ) -> None: - """Hot-update disabled skills (mirrors harness_agent.HarnessAgent).""" + """Hot-update disabled skills (mirrors octop_harness.HarnessAgent).""" self.config.skills_disabled = frozenset(str(x) for x in (disabled or ())) def set_tools_disabled( self, disabled: set[str] | frozenset[str] | list[str] | None, ) -> None: - """Hot-update disabled tools (mirrors harness_agent.HarnessAgent).""" + """Hot-update disabled tools (mirrors octop_harness.HarnessAgent).""" self.config.tools_disabled = frozenset(str(x) for x in (disabled or ())) def set_skill_package_roots(self, roots: list[dict[str, str]] | None) -> None: - """Hot-update skill package roots (mirrors harness_agent.HarnessAgent).""" + """Hot-update skill package roots (mirrors octop_harness.HarnessAgent).""" self.config.skill_package_roots = roots async def list_skill_summaries(self) -> list[dict[str, Any]]: diff --git a/tests/support/harness.py b/tests/support/harness.py index 9872a211c5..f7f068fcbc 100644 --- a/tests/support/harness.py +++ b/tests/support/harness.py @@ -1,4 +1,4 @@ -"""Patch harness-agent so tests run without a real LLM.""" +"""Patch octop-harness so tests run without a real LLM.""" from __future__ import annotations @@ -50,7 +50,7 @@ def build_harness_manager_mock( providers: list[Any] | None = None, ) -> MagicMock: """Return a MagicMock that quacks like HarnessAgentManager.""" - from harness_agent.manager import AgentEntry # type: ignore[import] + from octop_harness.manager import AgentEntry # type: ignore[import] agent = fake_agent if fake_agent is not None else FakeHarnessAgent() entries: dict[str, AgentEntry] = {} diff --git a/tests/unit/agents/test_agent_avatar.py b/tests/unit/agents/test_agent_avatar.py index 2a9f6cb965..ad6321a913 100644 --- a/tests/unit/agents/test_agent_avatar.py +++ b/tests/unit/agents/test_agent_avatar.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest -from harness_agent.backends.utils import BackendOperationNotSupportedError +from octop_harness.backends.utils import BackendOperationNotSupportedError from octop.infra.agents.avatar import ( MAX_AVATAR_BYTES, diff --git a/tests/unit/agents/test_agent_manager.py b/tests/unit/agents/test_agent_manager.py index a226a04b08..c5224f4285 100644 --- a/tests/unit/agents/test_agent_manager.py +++ b/tests/unit/agents/test_agent_manager.py @@ -160,7 +160,7 @@ def test_strip_team_host_runtime_tools_drops_mcp(manager: AgentManager) -> None: def test_apply_team_host_config_forces_async_ask_agent(manager: AgentManager) -> None: from dataclasses import replace as dc_replace - from harness_agent.config import HarnessAgentConfig + from octop_harness.config import HarnessAgentConfig manager._teams.member_ids = MagicMock(return_value=["mem-a", "mem-b"]) # type: ignore[method-assign] cfg = HarnessAgentConfig(workspace_dir=manager.paths.ensure_agent_workspace("host")) @@ -177,7 +177,7 @@ def test_apply_team_host_config_forces_async_ask_agent(manager: AgentManager) -> def test_apply_expert_config_forces_sync_ask_agent(manager: AgentManager) -> None: - from harness_agent.config import HarnessAgentConfig + from octop_harness.config import HarnessAgentConfig cfg = HarnessAgentConfig(workspace_dir=manager.paths.ensure_agent_workspace("expert")) out = manager._apply_team_host_config(cfg, _row(agent_id="expert")) @@ -1146,9 +1146,9 @@ def _fail_rmtree(path: object) -> None: def test_bootstrap_pending_detects_unfinished_onboarding(tmp_path: Path) -> None: - from harness_agent.backends import resolve_backend - from harness_agent.backends.workspace import BackendWorkspace - from harness_agent.middleware.bootstrap import bootstrap_marker_exists + from octop_harness.backends import resolve_backend + from octop_harness.backends.workspace import BackendWorkspace + from octop_harness.middleware.bootstrap import bootstrap_marker_exists backend = resolve_backend( {"type": "filesystem", "root_dir": str(tmp_path), "virtual_mode": False}, @@ -1195,8 +1195,8 @@ def test_build_harness_config_keeps_fs_permissions_for_local_shell_guard( Octop must not re-mount the guard (or ModelSettings) via cfg.middleware. """ - from harness_agent.middleware.filesystem_guard import FilesystemGuardMiddleware - from harness_agent.middleware.model_settings import ModelSettingsMiddleware + from octop_harness.middleware.filesystem_guard import FilesystemGuardMiddleware + from octop_harness.middleware.model_settings import ModelSettingsMiddleware cfg = manager._build_harness_config( _row(config_json=json.dumps({"backend": {"type": "local_shell", "virtual_mode": True}})), @@ -1329,7 +1329,7 @@ def test_build_harness_config_tolerates_bad_config_json(manager: AgentManager) - @pytest.mark.asyncio async def test_start_agent_real_harness_seeds_agents_md(manager: AgentManager) -> None: """Uses real HarnessAgentManager — no LLM call, only workspace init.""" - from harness_agent import HarnessAgentManager + from octop_harness import HarnessAgentManager _seed_test_provider(manager) manager._harness_manager = HarnessAgentManager( @@ -1345,7 +1345,7 @@ async def test_start_agent_real_harness_seeds_agents_md(manager: AgentManager) - row = manager._repos.agent_repo.get("REAL01") assert row is not None - from harness_agent import HarnessAgent + from octop_harness import HarnessAgent agent = await manager._start_agent(row) assert isinstance(agent, HarnessAgent) @@ -1360,7 +1360,7 @@ async def test_start_agent_real_harness_seeds_agents_md(manager: AgentManager) - @pytest.mark.asyncio async def test_stop_and_start_round_trip(manager: AgentManager) -> None: - from harness_agent import HarnessAgentManager + from octop_harness import HarnessAgentManager _seed_test_provider(manager) manager._harness_manager = HarnessAgentManager( @@ -1395,8 +1395,8 @@ async def test_stop_and_start_round_trip(manager: AgentManager) -> None: @pytest.mark.asyncio -async def test_save_security_rebuilds_running_harness_agent(manager: AgentManager) -> None: - from harness_agent import HarnessAgentManager +async def test_save_security_rebuilds_running_octop_harness(manager: AgentManager) -> None: + from octop_harness import HarnessAgentManager _seed_test_provider(manager) manager._harness_manager = HarnessAgentManager( @@ -1425,7 +1425,7 @@ async def test_save_security_rebuilds_running_harness_agent(manager: AgentManage @pytest.mark.asyncio async def test_reload_skips_stopped_agent(manager: AgentManager) -> None: - from harness_agent import HarnessAgentManager + from octop_harness import HarnessAgentManager _seed_test_provider(manager) manager._harness_manager = HarnessAgentManager( @@ -1456,7 +1456,7 @@ async def test_reload_skips_stopped_agent(manager: AgentManager) -> None: @pytest.mark.asyncio async def test_create_seeds_bootstrap_files(manager: AgentManager) -> None: """create() must seed harness workspace (BOOTSTRAP.md, AGENTS.md, …) before start.""" - from harness_agent import HarnessAgentManager + from octop_harness import HarnessAgentManager from octop.infra.agents.manager import AgentCreateSpec @@ -1487,7 +1487,7 @@ async def test_create_seeds_bootstrap_files(manager: AgentManager) -> None: @pytest.mark.asyncio async def test_start_team_host_does_not_copy_builtin_skills(manager: AgentManager) -> None: """Team hosts skip harness init_workspace so skills are never copied.""" - from harness_agent import HarnessAgent, HarnessAgentManager + from octop_harness import HarnessAgent, HarnessAgentManager _seed_test_provider(manager) manager._harness_manager = HarnessAgentManager( @@ -1519,7 +1519,7 @@ async def test_create_keeps_user_workspace_dir( tmp_path: Path, ) -> None: """Explicit config.workspace_dir must not be replaced by the scoped default.""" - from harness_agent import HarnessAgentManager + from octop_harness import HarnessAgentManager from octop.infra.agents.manager import AgentCreateSpec @@ -1558,7 +1558,7 @@ async def test_create_persists_rootfs_workspace_under_scoped_root( tmp_path: Path, ) -> None: """Non-host root_dir → config.workspace_dir is rootfs-absolute under that root.""" - from harness_agent import HarnessAgentManager + from octop_harness import HarnessAgentManager from octop.infra.agents.manager import AgentCreateSpec @@ -1628,7 +1628,7 @@ def test_resolve_workspace_dir_backfills_legacy_row(manager: AgentManager) -> No @pytest.mark.asyncio async def test_templated_agent_keeps_expert_soul_on_reload(manager: AgentManager) -> None: """Reload must not overwrite expert template SOUL.md with persona defaults.""" - from harness_agent import HarnessAgentManager + from octop_harness import HarnessAgentManager from octop.infra.agents.experts.catalog import ExpertCatalog from octop.infra.agents.manager import AgentCreateSpec @@ -1988,7 +1988,7 @@ def test_build_mcp_configs_shared_agent_uses_connector_user_override(manager: Ag def test_mcp_tool_filter_uses_server_prefix(manager: AgentManager) -> None: """Harness exposes MCP tools as {mcp_server_name}_{tool}; chat filters by prefix.""" - from harness_agent.mcp import filter_tools_for_mcp_servers, mcp_tool_names + from octop_harness.mcp import filter_tools_for_mcp_servers, mcp_tool_names mcp_name = "tencent-ima__01INST" tools = [{"name": f"{mcp_name}_list_notes"}, {"name": f"{mcp_name}_search_notes"}] diff --git a/tests/unit/agents/test_agent_registry.py b/tests/unit/agents/test_agent_registry.py index c4e536111b..cc465af7ac 100644 --- a/tests/unit/agents/test_agent_registry.py +++ b/tests/unit/agents/test_agent_registry.py @@ -1179,7 +1179,7 @@ async def test_create_with_unknown_template_does_not_crash(tmp_path: Path) -> No def _spy_resolve_backend(monkeypatch) -> list[Any]: """Record every ``resolve_backend`` call made while resolving a workspace.""" - import harness_agent.backends as harness_backends # noqa: PLC0415 + import octop_harness.backends as harness_backends # noqa: PLC0415 calls: list[Any] = [] original = harness_backends.resolve_backend diff --git a/tests/unit/agents/test_agent_tools.py b/tests/unit/agents/test_agent_tools.py index d579b2aa6a..7261ac6f11 100644 --- a/tests/unit/agents/test_agent_tools.py +++ b/tests/unit/agents/test_agent_tools.py @@ -2,8 +2,8 @@ from __future__ import annotations -from harness_agent.messages import extract_call_response -from harness_agent.teams.tools import build_team_tools +from octop_harness.messages import extract_call_response +from octop_harness.teams.tools import build_team_tools def test_extract_call_response_last_assistant() -> None: diff --git a/tests/unit/agents/test_clinical_learning_subscription_template.py b/tests/unit/agents/test_clinical_learning_subscription_template.py index e0e5a8318f..d5775b43a8 100644 --- a/tests/unit/agents/test_clinical_learning_subscription_template.py +++ b/tests/unit/agents/test_clinical_learning_subscription_template.py @@ -8,7 +8,7 @@ from types import ModuleType import pytest -from harness_agent.subagents.loader import parse_agent_markdown +from octop_harness.subagents.loader import parse_agent_markdown from octop.infra.agents.experts.catalog import default_library_root diff --git a/tests/unit/agents/test_context_breakdown.py b/tests/unit/agents/test_context_breakdown.py index d4422b1275..57338b4a2d 100644 --- a/tests/unit/agents/test_context_breakdown.py +++ b/tests/unit/agents/test_context_breakdown.py @@ -1,4 +1,4 @@ -"""Tests for Octop context-usage adapter over harness-agent.""" +"""Tests for Octop context-usage adapter over octop-harness.""" from __future__ import annotations @@ -20,8 +20,8 @@ def test_segment_keys_stable() -> None: def test_segment_keys_match_harness_when_available() -> None: - pytest.importorskip("harness_agent.context_usage") - from harness_agent.context_usage import SEGMENT_KEYS as HARNESS_KEYS + pytest.importorskip("octop_harness.context_usage") + from octop_harness.context_usage import SEGMENT_KEYS as HARNESS_KEYS assert SEGMENT_KEYS == HARNESS_KEYS diff --git a/tests/unit/agents/test_expert_publish_snapshot.py b/tests/unit/agents/test_expert_publish_snapshot.py index 63f5fea352..109fbd5687 100644 --- a/tests/unit/agents/test_expert_publish_snapshot.py +++ b/tests/unit/agents/test_expert_publish_snapshot.py @@ -8,7 +8,7 @@ import pytest from deepagents.backends.local_shell import LocalShellBackend -from harness_agent.backends.workspace import BackendWorkspace +from octop_harness.backends.workspace import BackendWorkspace from octop.infra.agents.builtin_skills import OCTOP_BUILTIN_SKILLS_ROOT from octop.infra.agents.experts.catalog import ( diff --git a/tests/unit/agents/test_hitl_session_policy.py b/tests/unit/agents/test_hitl_session_policy.py index 1b24826799..77f4233c0c 100644 --- a/tests/unit/agents/test_hitl_session_policy.py +++ b/tests/unit/agents/test_hitl_session_policy.py @@ -8,7 +8,7 @@ from unittest.mock import MagicMock import pytest -from harness_agent.security.models import SecurityPolicy +from octop_harness.security.models import SecurityPolicy from octop.config import OctopConfig from octop.infra.agents.manager import AgentManager diff --git a/tests/unit/agents/test_mcp_tool_cache.py b/tests/unit/agents/test_mcp_tool_cache.py index 540620a18b..bd070f9243 100644 --- a/tests/unit/agents/test_mcp_tool_cache.py +++ b/tests/unit/agents/test_mcp_tool_cache.py @@ -93,7 +93,7 @@ async def test_get_or_load_caches_and_reuses() -> None: aload = AsyncMock(return_value=[fake]) spec = {"transport": "stdio", "command": "npx", "args": []} - with patch("harness_agent.mcp.aload_mcp_tools", aload): + with patch("octop_harness.mcp.aload_mcp_tools", aload): first = await mgr._get_or_load_mcp_tools(1, "s", spec) second = await mgr._get_or_load_mcp_tools(1, "s", spec) @@ -124,7 +124,7 @@ def _tool(name: str) -> MagicMock: aload = AsyncMock(side_effect=[[_tool("s_a")], [_tool("s_b")]]) - with patch("harness_agent.mcp.aload_mcp_tools", aload): + with patch("octop_harness.mcp.aload_mcp_tools", aload): a = await mgr._get_or_load_mcp_tools( 1, "s", {"transport": "stdio", "command": "npx", "args": ["a"]} ) @@ -181,7 +181,7 @@ async def test_prepare_chat_mcp_injects_custom_from_cache() -> None: } mgr.reload_connectors = AsyncMock() - with patch("harness_agent.mcp.aload_mcp_tools", aload): + with patch("octop_harness.mcp.aload_mcp_tools", aload): failed = await mgr.prepare_chat_mcp("A1", ["deepwiki"], connector_user_id=7) failed2 = await mgr.prepare_chat_mcp("A1", ["deepwiki"], connector_user_id=7) diff --git a/tests/unit/agents/test_memory_slim.py b/tests/unit/agents/test_memory_slim.py index e91b8c67d3..f8de9b34db 100644 --- a/tests/unit/agents/test_memory_slim.py +++ b/tests/unit/agents/test_memory_slim.py @@ -19,7 +19,7 @@ def make_manager(tmp_path): - from harness_memory import Memory + from octop_memory import Memory path = tmp_path / "memory.sqlite" conn = sqlite3.connect(path) @@ -43,7 +43,7 @@ def make_manager(tmp_path): @pytest.mark.asyncio async def test_live_job_waits_for_turn_and_releases_gate(tmp_path, monkeypatch): - from harness_memory.application import checkpoint_maintenance + from octop_memory.application import checkpoint_maintenance coordinator, registry, memory, cfg, cp = make_manager(tmp_path) entered, release = threading.Event(), threading.Event() @@ -77,7 +77,7 @@ def slow(*args, **kwargs): @pytest.mark.asyncio async def test_live_failure_releases_gate_and_keeps_error(tmp_path, monkeypatch): - from harness_memory.application import checkpoint_maintenance + from octop_memory.application import checkpoint_maintenance coordinator, registry, memory, _, _ = make_manager(tmp_path) @@ -98,7 +98,7 @@ def fail(*args, **kwargs): @pytest.mark.asyncio async def test_cancellation_does_not_release_gate_before_worker_finishes(tmp_path, monkeypatch): - from harness_memory.application import checkpoint_maintenance + from octop_memory.application import checkpoint_maintenance coordinator, registry, memory, _, _ = make_manager(tmp_path) entered, release = threading.Event(), threading.Event() @@ -125,7 +125,7 @@ def slow(*args, **kwargs): @pytest.mark.asyncio async def test_manager_shutdown_waits_for_maintenance_before_closing_agents(tmp_path, monkeypatch): - from harness_memory.application import checkpoint_maintenance + from octop_memory.application import checkpoint_maintenance coordinator, registry, memory, _, _ = make_manager(tmp_path) entered, release = threading.Event(), threading.Event() @@ -373,7 +373,7 @@ def get_agent(agent_id): @pytest.mark.asyncio async def test_control_discovery_and_heartbeat_during_unchanged_phase(tmp_path, monkeypatch): - from harness_memory.application import checkpoint_maintenance + from octop_memory.application import checkpoint_maintenance from octop.infra.agents.memory_slim_control import list_memory_slim_agents @@ -681,7 +681,7 @@ def test_status_api_preserves_manual_terminal_result(phase): @pytest.mark.parametrize("reason", ["postgres", "disabled", "stopped", "upgrade"]) def test_preview_reports_specific_ineligibility_without_starting(tmp_path, monkeypatch, reason): - from harness_memory.application import checkpoint_maintenance + from octop_memory.application import checkpoint_maintenance from octop.infra.errors import ErrorCode, OctopError diff --git a/tests/unit/agents/test_provider_harness_factory.py b/tests/unit/agents/test_provider_harness_factory.py index 1ad710cb42..5c73b4c78a 100644 --- a/tests/unit/agents/test_provider_harness_factory.py +++ b/tests/unit/agents/test_provider_harness_factory.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock -from harness_agent.config import ProviderConfig +from octop_harness.config import ProviderConfig from octop.infra.agents.providers.harness_factory import sync_providers_to_harness diff --git a/tests/unit/agents/test_team_welcome.py b/tests/unit/agents/test_team_welcome.py index 02e0afe666..5ab4e73fdb 100644 --- a/tests/unit/agents/test_team_welcome.py +++ b/tests/unit/agents/test_team_welcome.py @@ -8,7 +8,7 @@ import pytest from deepagents.backends.local_shell import LocalShellBackend -from harness_agent.backends.workspace import BackendWorkspace +from octop_harness.backends.workspace import BackendWorkspace from octop.infra.agents.teams.welcome import team_host_welcome_payload from octop.infra.db.repos.agents import AgentRow diff --git a/tests/unit/agents/test_workspace_image_middleware.py b/tests/unit/agents/test_workspace_image_middleware.py index 5a6f8f6189..f2d47dd57d 100644 --- a/tests/unit/agents/test_workspace_image_middleware.py +++ b/tests/unit/agents/test_workspace_image_middleware.py @@ -9,8 +9,8 @@ import pytest from deepagents.backends.local_shell import LocalShellBackend -from harness_agent.backends.workspace import BackendWorkspace from langchain_core.messages import HumanMessage +from octop_harness.backends.workspace import BackendWorkspace from octop.api.common.attachments import save_attachment from octop.infra.agents.middleware.workspace_image import WorkspaceImageMaterializeMiddleware diff --git a/tests/unit/api/test_attachments.py b/tests/unit/api/test_attachments.py index e54c7c09c2..790194e4ac 100644 --- a/tests/unit/api/test_attachments.py +++ b/tests/unit/api/test_attachments.py @@ -7,8 +7,8 @@ import pytest from deepagents.backends.local_shell import LocalShellBackend -from harness_agent.backends.workspace import BackendWorkspace -from harness_gateway.models import FileContent, InboundMessage +from octop_gateway.models import FileContent, InboundMessage +from octop_harness.backends.workspace import BackendWorkspace from octop.api.common.attachments import dashboard_inbound_preview_url, save_attachment from octop.infra.errors import ErrorCode, OctopError @@ -213,7 +213,7 @@ async def test_save_attachment_pdf_uses_extension() -> None: @pytest.mark.asyncio async def test_build_content_from_file_part_uses_resolved_path() -> None: - from harness_gateway.models import TextContent + from octop_gateway.models import TextContent with tempfile.TemporaryDirectory() as ws_dir: workspace = _workspace(ws_dir) diff --git a/tests/unit/api/test_browser_stream_listen.py b/tests/unit/api/test_browser_stream_listen.py index a255f78e9c..1d5df979b2 100644 --- a/tests/unit/api/test_browser_stream_listen.py +++ b/tests/unit/api/test_browser_stream_listen.py @@ -102,7 +102,7 @@ async def test_resolve_harness_session_create_false_returns_none() -> None: hb = SimpleNamespace(BrowserSession=object, tool_interface=tool_iface) with patch.dict( sys.modules, - {"harness_browser": hb, "harness_browser.tool_interface": tool_iface}, + {"octop_browser": hb, "octop_browser.tool_interface": tool_iface}, ): result = await resolve_harness_session("default", create=False) assert result is None @@ -115,7 +115,7 @@ async def test_resolve_harness_session_never_falls_back_to_another_profile() -> hb = SimpleNamespace(BrowserSession=object, tool_interface=tool_iface) with patch.dict( sys.modules, - {"harness_browser": hb, "harness_browser.tool_interface": tool_iface}, + {"octop_browser": hb, "octop_browser.tool_interface": tool_iface}, ): result = await resolve_harness_session("user-1", create=False) assert result is None @@ -131,7 +131,7 @@ async def test_resolve_harness_session_create_true_rejects_empty_hint() -> None: with ( patch.dict( sys.modules, - {"harness_browser": hb, "harness_browser.tool_interface": tool_iface}, + {"octop_browser": hb, "octop_browser.tool_interface": tool_iface}, ), pytest.raises(OctopError, match="browser profile is required"), ): diff --git a/tests/unit/api/test_chat_history.py b/tests/unit/api/test_chat_history.py index 35b81d50da..4f4625d310 100644 --- a/tests/unit/api/test_chat_history.py +++ b/tests/unit/api/test_chat_history.py @@ -72,9 +72,9 @@ def test_slice_message_page_long_thread_no_gaps_or_overlaps() -> None: def test_isolated_sqlite_history_supports_pypi_memory_0_9_7(tmp_path: Path) -> None: - from harness_memory import Memory from langchain_core.messages import AIMessage from langgraph.checkpoint.base import empty_checkpoint + from octop_memory import Memory db_path = tmp_path / "checkpoint.db" memory = Memory( @@ -94,7 +94,7 @@ def test_isolated_sqlite_history_supports_pypi_memory_0_9_7(tmp_path: Path) -> N {"messages": 1}, ) # Prove the migration opens its own read-only connection rather than using - # harness-memory 0.9.7's live synchronous saver connection. + # octop-memory 0.9.7's live synchronous saver connection. memory._checkpointer.conn.close() try: harness = SimpleNamespace(_checkpointer_instance=memory) @@ -110,7 +110,7 @@ def test_isolated_sqlite_history_supports_pypi_memory_0_9_7(tmp_path: Path) -> N def test_isolated_history_reads_compact_checkpoint_with_closed_live_connection( tmp_path: Path, warm_cache: bool ) -> None: - compact = pytest.importorskip("harness_memory.storage.backends.sqlite_checkpoint") + compact = pytest.importorskip("octop_memory.storage.backends.sqlite_checkpoint") from langgraph.checkpoint.base import empty_checkpoint path = tmp_path / "compact.sqlite" diff --git a/tests/unit/api/test_chat_welcome.py b/tests/unit/api/test_chat_welcome.py index e2ebf85605..15685ac429 100644 --- a/tests/unit/api/test_chat_welcome.py +++ b/tests/unit/api/test_chat_welcome.py @@ -8,7 +8,7 @@ import pytest from deepagents.backends.local_shell import LocalShellBackend -from harness_agent.backends.workspace import BackendWorkspace +from octop_harness.backends.workspace import BackendWorkspace from octop.infra.agents.experts.catalog import ( ExpertCatalog, diff --git a/tests/unit/api/test_harness_tabs.py b/tests/unit/api/test_harness_tabs.py index b79c502a94..46634fa776 100644 --- a/tests/unit/api/test_harness_tabs.py +++ b/tests/unit/api/test_harness_tabs.py @@ -96,10 +96,10 @@ async def test_shutdown_browser_forces_current_user_profile() -> None: result = SimpleNamespace(success=True, error=None) with ( patch( - "harness_browser.tool_interface.browser_tool", + "octop_browser.tool_interface.browser_tool", new=AsyncMock(return_value=result), ) as tool, - patch("harness_browser.tool_interface._registry", {"work": object()}), + patch("octop_browser.tool_interface._registry", {"work": object()}), ): body = await harness_mod.shutdown_browser( user=SimpleNamespace(id=7), diff --git a/tests/unit/api/test_setup_main_agent.py b/tests/unit/api/test_setup_main_agent.py index 2313eea500..4981bd1143 100644 --- a/tests/unit/api/test_setup_main_agent.py +++ b/tests/unit/api/test_setup_main_agent.py @@ -6,8 +6,8 @@ import tempfile import pytest -from harness_agent.backends import resolve_backend -from harness_agent.backends.workspace import BackendWorkspace +from octop_harness.backends import resolve_backend +from octop_harness.backends.workspace import BackendWorkspace from octop.infra.agents.experts.catalog import ExpertCatalog, default_library_root diff --git a/tests/unit/api/test_terminal_sessions.py b/tests/unit/api/test_terminal_sessions.py index 1da72604a5..b7d0d672ca 100644 --- a/tests/unit/api/test_terminal_sessions.py +++ b/tests/unit/api/test_terminal_sessions.py @@ -1,6 +1,6 @@ """Unit tests for the terminal session machinery and WS handler. -These run without the full ``OctopServer`` / ``harness_gateway`` stack: +These run without the full ``OctopServer`` / ``octop_gateway`` stack: the ``terminal`` router module imports cleanly on its own, so we drive it with lightweight fakes for the server, the WebSocket and the PTY spawn. Pure-logic cases run on any platform; PTY-path cases are gated diff --git a/tests/unit/backend/test_resolver.py b/tests/unit/backend/test_resolver.py index a9f9845e23..0437fdbda5 100644 --- a/tests/unit/backend/test_resolver.py +++ b/tests/unit/backend/test_resolver.py @@ -10,8 +10,8 @@ import pytest from deepagents.backends import CompositeBackend -from harness_agent.backends import resolve_backend -from harness_agent.backends.workspace import BackendWorkspace +from octop_harness.backends import resolve_backend +from octop_harness.backends.workspace import BackendWorkspace from octop.infra.backend.resolver import ( _is_host_root, diff --git a/tests/unit/backup/test_workspace_archive.py b/tests/unit/backup/test_workspace_archive.py index b24f701704..e721031d1e 100644 --- a/tests/unit/backup/test_workspace_archive.py +++ b/tests/unit/backup/test_workspace_archive.py @@ -10,8 +10,8 @@ import pytest from deepagents.backends.local_shell import LocalShellBackend -from harness_agent.backends import resolve_backend -from harness_agent.backends.workspace import BackendWorkspace +from octop_harness.backends import resolve_backend +from octop_harness.backends.workspace import BackendWorkspace from octop.infra.backup.workspace_archive import export_workspace_zip, import_workspace_zip diff --git a/tests/unit/browser/test_browser_setup.py b/tests/unit/browser/test_browser_setup.py index 071d996de6..26d6111be9 100644 --- a/tests/unit/browser/test_browser_setup.py +++ b/tests/unit/browser/test_browser_setup.py @@ -29,7 +29,7 @@ def test_configure_browser_idle_timeout_updates_harness( monkeypatch: pytest.MonkeyPatch, ) -> None: - from harness_browser.settings import settings as hb_settings + from octop_browser.settings import settings as hb_settings from octop.infra.utils.browser_media import configure_browser_idle_timeout @@ -158,8 +158,8 @@ def test_non_linux_temp_dirs_use_gettempdir_with_stable_token( runtime = _runtime_dir_for_uid(uid=7) profiles = _relocated_profiles_root_for_uid(uid=7) - assert runtime == fake_tmp / "runtime-harness-browser-7" - assert profiles == fake_tmp / "harness-browser-profiles-7" + assert runtime == fake_tmp / "runtime-octop-browser-7" + assert profiles == fake_tmp / "octop-browser-profiles-7" # Same inputs → same paths across "process restarts" (no pid in the name). assert _runtime_dir_for_uid(uid=7) == runtime assert _relocated_profiles_root_for_uid(uid=7) == profiles @@ -266,7 +266,7 @@ def test_ensure_profile_writable_recreates_when_not_writable( monkeypatch.setattr(browser_setup, "_under_root_home", lambda _p: False) try: result = ensure_profile_writable(profile) - assert result == profile or "harness-browser-profiles" in str(result) + assert result == profile or "octop-browser-profiles" in str(result) assert _probe_dir_writable(result) finally: if profile.exists(): @@ -318,7 +318,7 @@ def tracking_chmod(path: str | bytes | os.PathLike[str], mode: int) -> None: monkeypatch.setattr(browser_setup, "_under_root_home", lambda _p: False) monkeypatch.setattr(browser_setup, "_probe_dir_writable", lambda _p: True) - profile = Path(f"/tmp/harness-browser-profiles-{os.getuid()}") / "default" + profile = Path(f"/tmp/octop-browser-profiles-{os.getuid()}") / "default" ensure_profile_writable(profile) # Profile prep no longer chmod/chown at all — recreate/relocate only. diff --git a/tests/unit/gateway/test_attachment_hints.py b/tests/unit/gateway/test_attachment_hints.py index 531ae4b579..f756499a26 100644 --- a/tests/unit/gateway/test_attachment_hints.py +++ b/tests/unit/gateway/test_attachment_hints.py @@ -9,8 +9,8 @@ import pytest from deepagents.backends.local_shell import LocalShellBackend -from harness_agent.backends.workspace import BackendWorkspace -from harness_gateway.models import FileContent, ImageContent, TextContent +from octop_gateway.models import FileContent, ImageContent, TextContent +from octop_harness.backends.workspace import BackendWorkspace from PIL import Image from octop.api.routers.chat.models import ChatTurnBody @@ -171,7 +171,7 @@ async def test_file_typed_image_path_still_materializes_via_content_parts() -> N parts = content_parts_from_dashboard_turn(turn) assert any(isinstance(p, ImageContent) for p in parts) backend = AgentBackedMediaBackend(workspace) - from harness_gateway.models import InboundMessage + from octop_gateway.models import InboundMessage msg = InboundMessage( channel_id=WS_CHANNEL_ID, diff --git a/tests/unit/gateway/test_attachment_path_resolution.py b/tests/unit/gateway/test_attachment_path_resolution.py index 2c8ef5a686..5aaf8c55bb 100644 --- a/tests/unit/gateway/test_attachment_path_resolution.py +++ b/tests/unit/gateway/test_attachment_path_resolution.py @@ -15,9 +15,9 @@ import pytest from deepagents.backends.local_shell import LocalShellBackend from deepagents.backends.utils import validate_path -from harness_agent.backends import DEFAULT_BACKEND_SPEC, resolve_backend -from harness_agent.backends.workspace import BackendWorkspace -from harness_gateway.models import ImageContent, InboundMessage +from octop_gateway.models import ImageContent, InboundMessage +from octop_harness.backends import DEFAULT_BACKEND_SPEC, resolve_backend +from octop_harness.backends.workspace import BackendWorkspace from octop.api.common.attachments import save_attachment from octop.infra.agents.middleware.binary_read_guard import read_file_block_reason diff --git a/tests/unit/gateway/test_backend_files.py b/tests/unit/gateway/test_backend_files.py index f8be4aacc5..84a1fdfa02 100644 --- a/tests/unit/gateway/test_backend_files.py +++ b/tests/unit/gateway/test_backend_files.py @@ -8,7 +8,7 @@ import pytest from deepagents.backends.local_shell import LocalShellBackend -from harness_agent.backends.workspace import BackendWorkspace +from octop_harness.backends.workspace import BackendWorkspace from octop.infra.gateway.media.backend_files import ( dashboard_media_url, diff --git a/tests/unit/gateway/test_chat_image_pipeline.py b/tests/unit/gateway/test_chat_image_pipeline.py index 81f56cc6d4..df865c184d 100644 --- a/tests/unit/gateway/test_chat_image_pipeline.py +++ b/tests/unit/gateway/test_chat_image_pipeline.py @@ -10,8 +10,8 @@ import pytest from deepagents.backends.local_shell import LocalShellBackend -from harness_agent.backends.workspace import BackendWorkspace -from harness_gateway.models import InboundMessage +from octop_gateway.models import InboundMessage +from octop_harness.backends.workspace import BackendWorkspace from octop.api.common.attachments import save_attachment from octop.api.routers.chat.models import ChatTurnBody @@ -173,7 +173,7 @@ async def test_file_in_workspace_preview_only_drops_image_for_llm() -> None: @pytest.mark.asyncio async def test_default_backend_materialize_still_inlines_image() -> None: """Production default backend: path ref in content; expand yields vision bytes.""" - from harness_agent.backends import resolve_backend + from octop_harness.backends import resolve_backend with tempfile.TemporaryDirectory() as ws_dir: workspace = BackendWorkspace( diff --git a/tests/unit/gateway/test_cli_channel.py b/tests/unit/gateway/test_cli_channel.py index 1eab7df5fa..2ac6d0cf2f 100644 --- a/tests/unit/gateway/test_cli_channel.py +++ b/tests/unit/gateway/test_cli_channel.py @@ -6,7 +6,7 @@ from typing import Any import pytest -from harness_gateway.models import ChannelSubject, InboundMessage, TextContent +from octop_gateway.models import ChannelSubject, InboundMessage, TextContent from octop.infra.gateway.cli import CLI_CHANNEL_ID, CLI_CONNECTION_META, CliChannel, CliHub diff --git a/tests/unit/gateway/test_dashboard_ws.py b/tests/unit/gateway/test_dashboard_ws.py index e40f776c98..51427c9a71 100644 --- a/tests/unit/gateway/test_dashboard_ws.py +++ b/tests/unit/gateway/test_dashboard_ws.py @@ -9,7 +9,7 @@ from typing import Any import pytest -from harness_gateway.models import ChannelSubject, ImageContent, InboundMessage, TextContent +from octop_gateway.models import ChannelSubject, ImageContent, InboundMessage, TextContent from octop.infra.gateway.media.tool_media import enrich_media_block_preview from octop.infra.gateway.ws import WS_CHANNEL_ID, WebSocketChannel, WebSocketHub diff --git a/tests/unit/gateway/test_discord_channel.py b/tests/unit/gateway/test_discord_channel.py index 172bf09dfa..e9b3516a8f 100644 --- a/tests/unit/gateway/test_discord_channel.py +++ b/tests/unit/gateway/test_discord_channel.py @@ -5,8 +5,8 @@ from types import SimpleNamespace import pytest -from harness_gateway.channels.discord import DiscordChannel, DiscordConfig -from harness_gateway.models import ChannelSubject, InboundMessage, MessageEvent +from octop_gateway.channels.discord import DiscordChannel, DiscordConfig +from octop_gateway.models import ChannelSubject, InboundMessage, MessageEvent from tests.unit.gateway.test_gateway_runtime_status import _make_gateway from octop.infra.gateway.gateway import ChannelCreateSpec, ChannelKind, Gateway @@ -73,8 +73,8 @@ async def test_discord_registration_forwards_config_and_routing(tmp_path, monkey import json from unittest.mock import AsyncMock, MagicMock - from harness_gateway.manager import ChannelManager - from harness_gateway.media import FileSystemMediaBackend + from octop_gateway.manager import ChannelManager + from octop_gateway.media import FileSystemMediaBackend async def processor(msg): yield MessageEvent.completed() diff --git a/tests/unit/gateway/test_gateway_push.py b/tests/unit/gateway/test_gateway_push.py index 4aa8ede84f..391a98926d 100644 --- a/tests/unit/gateway/test_gateway_push.py +++ b/tests/unit/gateway/test_gateway_push.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from harness_gateway.models import ChannelSubject +from octop_gateway.models import ChannelSubject from octop.infra.db.migrate import run_migrations from octop.infra.db.pool import SqlitePool diff --git a/tests/unit/gateway/test_gateway_runtime_status.py b/tests/unit/gateway/test_gateway_runtime_status.py index 8a312c4d7b..04de8648f5 100644 --- a/tests/unit/gateway/test_gateway_runtime_status.py +++ b/tests/unit/gateway/test_gateway_runtime_status.py @@ -164,7 +164,7 @@ async def test_reload_channels_from_db_unregisters_old_and_registers_enabled( @pytest.mark.asyncio async def test_probe_weixin_without_token_fails_before_start(tmp_path: Path) -> None: - from harness_gateway.manager import ChannelManager + from octop_gateway.manager import ChannelManager gw = _make_gateway(tmp_path) gw._channel_manager = ChannelManager() diff --git a/tests/unit/gateway/test_global_processor_team.py b/tests/unit/gateway/test_global_processor_team.py index df3323e5a5..888310a70a 100644 --- a/tests/unit/gateway/test_global_processor_team.py +++ b/tests/unit/gateway/test_global_processor_team.py @@ -7,8 +7,8 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from harness_agent.teams.inbox import InboxMessage -from harness_agent.teams.processor import ReplyEvent +from octop_harness.teams.inbox import InboxMessage +from octop_harness.teams.processor import ReplyEvent from octop.infra.db.migrate import run_migrations from octop.infra.db.pool import SqlitePool @@ -272,8 +272,8 @@ async def capture(frame: dict[str, object]) -> None: async def test_stream_host_followup_marks_live_and_persists( processor_env: dict, ) -> None: - from harness_agent.request import ChatRequest from langchain_core.messages import AIMessage + from octop_harness.request import ChatRequest processor = processor_env["processor"] gateway = processor_env["gateway"] @@ -326,7 +326,7 @@ async def test_stream_host_followup_waits_for_dispatch_turn( ) -> None: import asyncio - from harness_agent.request import ChatRequest + from octop_harness.request import ChatRequest processor = processor_env["processor"] gateway = processor_env["gateway"] @@ -358,8 +358,8 @@ async def fake_stream(_agent_id: str, _request: dict[str, object]) -> object: async def test_stream_host_followup_unwatched_does_not_skip_snapshot( processor_env: dict, ) -> None: - from harness_agent.request import ChatRequest from langchain_core.messages import AIMessage + from octop_harness.request import ChatRequest processor = processor_env["processor"] processor._agent_repo.create(agent_id="host", user_id=1, name="Host", kind="team") @@ -389,8 +389,8 @@ async def fake_stream(_agent_id: str, _request: dict[str, object]) -> object: async def test_stream_host_followup_pushes_wrapup_when_no_tokens( processor_env: dict, ) -> None: - from harness_agent.request import ChatRequest from langchain_core.messages import AIMessage + from octop_harness.request import ChatRequest processor = processor_env["processor"] gateway = processor_env["gateway"] @@ -431,8 +431,8 @@ async def fake_stream(_agent_id: str, _request: dict[str, object]) -> object: @pytest.mark.asyncio async def test_record_peer_turn_persists_room_user_question(processor_env: dict) -> None: - from harness_agent.teams.util import PeerCall from langchain_core.messages import AIMessage, HumanMessage + from octop_harness.teams.util import PeerCall processor = processor_env["processor"] processor._agent_repo.create(agent_id="host", user_id=1, name="Host", kind="team") @@ -481,8 +481,8 @@ async def test_record_peer_turn_persists_room_user_question(processor_env: dict) @pytest.mark.asyncio async def test_record_peer_turn_keeps_sync_ask_agent_message(processor_env: dict) -> None: - from harness_agent.teams.util import PeerCall from langchain_core.messages import AIMessage, HumanMessage + from octop_harness.teams.util import PeerCall processor = processor_env["processor"] repo = MagicMock() @@ -536,7 +536,7 @@ async def test_ensure_projection_leaves_pending_threads_with_history( async def test_prepare_peer_session_creates_callee_thread_without_rebind( processor_env: dict, ) -> None: - from harness_agent.teams.util import PeerCall + from octop_harness.teams.util import PeerCall processor = processor_env["processor"] parent_sk = processor_env["parent_sk"] @@ -562,8 +562,8 @@ async def test_prepare_peer_session_creates_callee_thread_without_rebind( @pytest.mark.asyncio async def test_prepare_team_peer_seeds_host_assignment(processor_env: dict) -> None: - from harness_agent.teams.util import PeerCall from langchain_core.messages import HumanMessage + from octop_harness.teams.util import PeerCall processor = processor_env["processor"] parent_sk = processor_env["parent_sk"] @@ -601,7 +601,7 @@ async def test_prepare_team_peer_seeds_host_assignment(processor_env: dict) -> N @pytest.mark.asyncio async def test_prepare_team_peer_uses_isolated_session_key(processor_env: dict) -> None: - from harness_agent.teams.util import PeerCall + from octop_harness.teams.util import PeerCall processor = processor_env["processor"] parent_sk = processor_env["parent_sk"] @@ -628,8 +628,8 @@ async def test_prepare_team_peer_uses_isolated_session_key(processor_env: dict) @pytest.mark.asyncio async def test_prepare_followup_seeds_new_user_question(processor_env: dict) -> None: - from harness_agent.teams.util import PeerCall from langchain_core.messages import HumanMessage + from octop_harness.teams.util import PeerCall processor = processor_env["processor"] parent_sk = processor_env["parent_sk"] @@ -686,7 +686,7 @@ async def test_prepare_followup_seeds_new_user_question(processor_env: dict) -> async def test_prepare_team_dispatch_tracks_job_and_forces_sync( processor_env: dict, ) -> None: - from harness_agent.teams.util import PeerCall + from octop_harness.teams.util import PeerCall from octop.infra.agents.teams import TeamJobTracker @@ -720,8 +720,8 @@ async def test_prepare_team_dispatch_tracks_job_and_forces_sync( async def test_prepare_team_dispatch_includes_room_history( processor_env: dict, ) -> None: - from harness_agent.teams.util import PeerCall from langchain_core.messages import AIMessage, HumanMessage + from octop_harness.teams.util import PeerCall processor = processor_env["processor"] parent_sk = processor_env["parent_sk"] @@ -765,8 +765,8 @@ async def test_prepare_team_dispatch_uses_projected_member_speech( ) -> None: import json - from harness_agent.teams.util import PeerCall from langchain_core.messages import AIMessage, HumanMessage + from octop_harness.teams.util import PeerCall processor = processor_env["processor"] processor._agent_repo.create(agent_id="host", user_id=1, name="Host", kind="team") @@ -847,8 +847,8 @@ def test_stamp_team_host_runtime_forces_async(processor_env: dict) -> None: @pytest.mark.asyncio async def test_stream_team_peer_relays_tokens_to_room(processor_env: dict) -> None: - from harness_agent.request import ChatRequest from langchain_core.messages import AIMessage + from octop_harness.request import ChatRequest processor = processor_env["processor"] gateway = processor_env["gateway"] @@ -891,8 +891,8 @@ async def fake_stream(_agent_id: str, _request: dict[str, object]) -> object: async def test_stream_team_peer_relays_tokens_to_member_and_room( processor_env: dict, ) -> None: - from harness_agent.request import ChatRequest from langchain_core.messages import AIMessage + from octop_harness.request import ChatRequest processor = processor_env["processor"] gateway = processor_env["gateway"] @@ -936,8 +936,8 @@ async def fake_stream(_agent_id: str, _request: dict[str, object]) -> object: async def test_stream_team_peer_unwatched_is_not_live_streamed( processor_env: dict, ) -> None: - from harness_agent.request import ChatRequest from langchain_core.messages import AIMessage + from octop_harness.request import ChatRequest processor = processor_env["processor"] @@ -959,8 +959,8 @@ async def fake_stream(_agent_id: str, _request: dict[str, object]) -> object: @pytest.mark.asyncio async def test_stream_team_peer_relays_reasoning_and_tools(processor_env: dict) -> None: - from harness_agent.request import ChatRequest from langchain_core.messages import AIMessage, HumanMessage + from octop_harness.request import ChatRequest processor = processor_env["processor"] gateway = processor_env["gateway"] @@ -1014,8 +1014,8 @@ async def fake_stream(_agent_id: str, _request: dict[str, object]) -> object: async def test_fan_in_persists_member_turn_without_dispatch_prompt( processor_env: dict, ) -> None: - from harness_agent.teams.util import PeerCall from langchain_core.messages import AIMessage, HumanMessage + from octop_harness.teams.util import PeerCall processor = processor_env["processor"] processor._agent_repo.create(agent_id="host", user_id=1, name="Host", kind="team") @@ -1048,8 +1048,8 @@ async def test_fan_in_persists_member_turn_without_dispatch_prompt( @pytest.mark.asyncio async def test_fan_in_skips_snapshot_after_live_stream(processor_env: dict) -> None: - from harness_agent.teams.util import PeerCall from langchain_core.messages import AIMessage + from octop_harness.teams.util import PeerCall processor = processor_env["processor"] gateway = processor_env["gateway"] @@ -1080,8 +1080,8 @@ async def capture(frame: dict[str, object]) -> None: @pytest.mark.asyncio async def test_fan_in_pushes_snapshot_when_not_live(processor_env: dict) -> None: - from harness_agent.teams.util import PeerCall from langchain_core.messages import AIMessage + from octop_harness.teams.util import PeerCall processor = processor_env["processor"] gateway = processor_env["gateway"] @@ -1141,8 +1141,8 @@ def _bind_im_session(processor_env: dict, *, thread_id: str = "thr_parent") -> s @pytest.mark.asyncio async def test_fan_in_pushes_member_speech_to_im_channel(processor_env: dict) -> None: - from harness_agent.teams.util import PeerCall from langchain_core.messages import AIMessage + from octop_harness.teams.util import PeerCall processor = processor_env["processor"] gateway = processor_env["gateway"] @@ -1172,8 +1172,8 @@ async def capture_push(channel_type: str, channel_id: str, subject: object, text @pytest.mark.asyncio async def test_fan_in_skips_dashboard_when_pushing_channels(processor_env: dict) -> None: - from harness_agent.teams.util import PeerCall from langchain_core.messages import AIMessage + from octop_harness.teams.util import PeerCall processor = processor_env["processor"] gateway = processor_env["gateway"] @@ -1232,8 +1232,8 @@ async def capture_push(channel_type: str, channel_id: str, subject: object, text @pytest.mark.asyncio async def test_fan_in_async_non_team_writes_caller_room(processor_env: dict) -> None: - from harness_agent.teams.util import PeerCall from langchain_core.messages import AIMessage + from octop_harness.teams.util import PeerCall processor = processor_env["processor"] repo = MagicMock() @@ -1261,8 +1261,8 @@ async def test_fan_in_async_non_team_writes_caller_room(processor_env: dict) -> @pytest.mark.asyncio async def test_fan_in_sync_non_team_skips_caller_room(processor_env: dict) -> None: - from harness_agent.teams.util import PeerCall from langchain_core.messages import AIMessage + from octop_harness.teams.util import PeerCall processor = processor_env["processor"] repo = MagicMock() diff --git a/tests/unit/gateway/test_harness_request.py b/tests/unit/gateway/test_harness_request.py index f112cbcddc..38e7b7ecc0 100644 --- a/tests/unit/gateway/test_harness_request.py +++ b/tests/unit/gateway/test_harness_request.py @@ -1,7 +1,7 @@ from __future__ import annotations import pytest -from harness_gateway.models import ( +from octop_gateway.models import ( ChannelSubject, FileContent, GroupContext, diff --git a/tests/unit/gateway/test_hitl_channel.py b/tests/unit/gateway/test_hitl_channel.py index c441a37b18..7fea2f91c3 100644 --- a/tests/unit/gateway/test_hitl_channel.py +++ b/tests/unit/gateway/test_hitl_channel.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock import pytest -from harness_gateway.models import MessageEvent, MessageEventType, TextContent +from octop_gateway.models import MessageEvent, MessageEventType, TextContent from octop.infra.gateway.hitl.coordinator import ( HitlChannelCoordinator, @@ -131,7 +131,7 @@ async def _resume(*_args: object, **_kwargs: object): ctx.channel_type = "feishu" ctx.thread_registry = MagicMock() - from harness_agent.slash import SlashCommand + from octop_harness.slash import SlashCommand cmd = SlashCommand(name="approve", args="") events: list[MessageEvent] = [] @@ -166,7 +166,7 @@ async def test_coordinator_slash_approve_invalid_pending_id() -> None: review_configs=None, ) - from harness_agent.slash import SlashCommand + from octop_harness.slash import SlashCommand ctx = MagicMock() ctx.session_key = "sk1" @@ -219,7 +219,7 @@ async def _fail(*_args: object, **_kwargs: object): ctx.channel_type = "feishu" ctx.thread_registry = MagicMock() - from harness_agent.slash import SlashCommand + from octop_harness.slash import SlashCommand cmd = SlashCommand(name="approve", args="") async for _ in coordinator.iter_slash_resolution( @@ -401,7 +401,7 @@ async def _resume(*_args: object, **_kwargs: object): ctx.channel_type = "feishu" ctx.thread_registry = MagicMock() - from harness_agent.slash import SlashCommand + from octop_harness.slash import SlashCommand outcome = HitlSlashOutcome() cmd = SlashCommand(name="approve", args="") diff --git a/tests/unit/gateway/test_inbound_store.py b/tests/unit/gateway/test_inbound_store.py index b257d9d386..737e4792eb 100644 --- a/tests/unit/gateway/test_inbound_store.py +++ b/tests/unit/gateway/test_inbound_store.py @@ -7,7 +7,7 @@ import pytest from deepagents.backends.local_shell import LocalShellBackend -from harness_agent.backends.workspace import BackendWorkspace +from octop_harness.backends.workspace import BackendWorkspace from octop.infra.gateway.media.inbound_store import ( build_timestamped_inbound_name, diff --git a/tests/unit/gateway/test_media_preview.py b/tests/unit/gateway/test_media_preview.py index d0a986b718..b9738186c7 100644 --- a/tests/unit/gateway/test_media_preview.py +++ b/tests/unit/gateway/test_media_preview.py @@ -8,7 +8,7 @@ import pytest from deepagents.backends.local_shell import LocalShellBackend -from harness_agent.backends.workspace import BackendWorkspace +from octop_harness.backends.workspace import BackendWorkspace from octop.infra.gateway.media.backend_files import ( is_previewable_mime, diff --git a/tests/unit/gateway/test_mention_agent_calls.py b/tests/unit/gateway/test_mention_agent_calls.py index 5aca3f0d61..e762da5fbc 100644 --- a/tests/unit/gateway/test_mention_agent_calls.py +++ b/tests/unit/gateway/test_mention_agent_calls.py @@ -1,4 +1,4 @@ -"""Team peer discovery after harness-agent dropped apply_mentions intercept.""" +"""Team peer discovery after octop-harness dropped apply_mentions intercept.""" from __future__ import annotations @@ -7,9 +7,9 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from harness_agent.config import HarnessAgentConfig, ModelConfig, ProviderConfig -from harness_agent.manager import HarnessAgentManager -from harness_agent.teams.tools import AskAgentInput, build_team_tools +from octop_harness.config import HarnessAgentConfig, ModelConfig, ProviderConfig +from octop_harness.manager import HarnessAgentManager +from octop_harness.teams.tools import AskAgentInput, build_team_tools def _config(tmp_path: Path, *, name: str = "agent") -> HarnessAgentConfig: @@ -32,7 +32,7 @@ def _mgr(tmp_path: Path) -> HarnessAgentManager: mock_agent = MagicMock() mock_agent.init_workspace.return_value = MagicMock() mock_agent.call = AsyncMock(return_value={"messages": [{"role": "assistant", "content": "ok"}]}) - with patch("harness_agent.manager.HarnessAgent", return_value=mock_agent): + with patch("octop_harness.manager.HarnessAgent", return_value=mock_agent): mgr = HarnessAgentManager() mgr.create_agent( _config(tmp_path, name="main"), @@ -64,7 +64,7 @@ def test_resolve_peer_matches_octop_display_name(tmp_path: Path) -> None: mock_agent = MagicMock() mock_agent.init_workspace.return_value = MagicMock() mock_agent.call = AsyncMock(return_value={"messages": [{"role": "assistant", "content": "ok"}]}) - with patch("harness_agent.manager.HarnessAgent", return_value=mock_agent): + with patch("octop_harness.manager.HarnessAgent", return_value=mock_agent): mgr = HarnessAgentManager() mgr.create_agent( _config(tmp_path, name="agent_host01"), @@ -94,7 +94,7 @@ async def test_ask_agent_accepts_expert_or_legacy_agent_key(tmp_path: Path) -> N mgr = _mgr(tmp_path) tools = {tool.name: tool for tool in build_team_tools(mgr.team)} runtime = {"configurable": {"agent_id": "main", "user": 1}} - with patch("harness_agent.teams.tools.get_config", return_value=runtime): + with patch("octop_harness.teams.tools.get_config", return_value=runtime): by_expert = json.loads( await tools["ask_agent"].ainvoke({"expert": "researcher", "message": "hi"}) ) diff --git a/tests/unit/gateway/test_message_build.py b/tests/unit/gateway/test_message_build.py index a20a318b1e..49f841f355 100644 --- a/tests/unit/gateway/test_message_build.py +++ b/tests/unit/gateway/test_message_build.py @@ -6,7 +6,7 @@ from pathlib import Path import pytest -from harness_gateway.models import ImageContent, TextContent +from octop_gateway.models import ImageContent, TextContent from octop.infra.gateway.media.tool_media import iter_media_blocks from octop.infra.gateway.process import ( @@ -40,7 +40,7 @@ def test_build_content_with_image_data(): def test_build_content_reads_media_backend_key(tmp_path: Path) -> None: - from harness_gateway.media import FileSystemMediaBackend + from octop_gateway.media import FileSystemMediaBackend media_root = tmp_path / "media" media_root.mkdir() diff --git a/tests/unit/gateway/test_message_keys.py b/tests/unit/gateway/test_message_keys.py index faebdbe183..a98b5effac 100644 --- a/tests/unit/gateway/test_message_keys.py +++ b/tests/unit/gateway/test_message_keys.py @@ -2,7 +2,7 @@ from __future__ import annotations -from harness_gateway.models import ChannelSubject, InboundMessage, TextContent +from octop_gateway.models import ChannelSubject, InboundMessage, TextContent from octop.infra.gateway.process.message_keys import ( resolve_user_id_for_message, diff --git a/tests/unit/gateway/test_preempt_stop_cancel.py b/tests/unit/gateway/test_preempt_stop_cancel.py index cad37b8810..165378f365 100644 --- a/tests/unit/gateway/test_preempt_stop_cancel.py +++ b/tests/unit/gateway/test_preempt_stop_cancel.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock import pytest -from harness_gateway.models import ChannelSubject, InboundMessage, TextContent +from octop_gateway.models import ChannelSubject, InboundMessage, TextContent from octop.infra.db.migrate import run_migrations from octop.infra.db.pool import SqlitePool diff --git a/tests/unit/gateway/test_processor_conversation_mode.py b/tests/unit/gateway/test_processor_conversation_mode.py index 75407c88f5..671974c419 100644 --- a/tests/unit/gateway/test_processor_conversation_mode.py +++ b/tests/unit/gateway/test_processor_conversation_mode.py @@ -6,8 +6,8 @@ from unittest.mock import MagicMock import pytest -from harness_gateway.models import ChannelSubject, InboundMessage, TextContent from langchain_core.messages import HumanMessage +from octop_gateway.models import ChannelSubject, InboundMessage, TextContent from octop.infra.gateway.process.processor import GlobalProcessor, _overwrite_last_user_text from octop.infra.gateway.slash.dispatcher import SlashDispatcher diff --git a/tests/unit/gateway/test_processor_default_open.py b/tests/unit/gateway/test_processor_default_open.py index 62709da3da..1c5c64265e 100644 --- a/tests/unit/gateway/test_processor_default_open.py +++ b/tests/unit/gateway/test_processor_default_open.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from harness_gateway.models import ChannelSubject, InboundMessage, TextContent +from octop_gateway.models import ChannelSubject, InboundMessage, TextContent from octop.infra.errors import ErrorCode, OctopError from octop.infra.gateway.process.processor import GlobalProcessor diff --git a/tests/unit/gateway/test_processor_resource_errors.py b/tests/unit/gateway/test_processor_resource_errors.py index 2b4706af65..f8bedb4c8f 100644 --- a/tests/unit/gateway/test_processor_resource_errors.py +++ b/tests/unit/gateway/test_processor_resource_errors.py @@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from harness_gateway.models import ChannelSubject, InboundMessage, TextContent +from octop_gateway.models import ChannelSubject, InboundMessage, TextContent from octop.infra.errors import ErrorCode, OctopError from octop.infra.gateway.process.history_projection import _wire_text diff --git a/tests/unit/gateway/test_processor_trajectory_observe.py b/tests/unit/gateway/test_processor_trajectory_observe.py index 537db1d8c5..e791a5d0e3 100644 --- a/tests/unit/gateway/test_processor_trajectory_observe.py +++ b/tests/unit/gateway/test_processor_trajectory_observe.py @@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from harness_gateway.models import ChannelSubject, InboundMessage, TextContent +from octop_gateway.models import ChannelSubject, InboundMessage, TextContent from octop.infra.gateway.process.processor import GlobalProcessor from octop.infra.gateway.slash.dispatcher import SlashDispatcher diff --git a/tests/unit/gateway/test_response_mode.py b/tests/unit/gateway/test_response_mode.py index e0524af0c1..f65f61a258 100644 --- a/tests/unit/gateway/test_response_mode.py +++ b/tests/unit/gateway/test_response_mode.py @@ -5,7 +5,7 @@ from collections.abc import AsyncIterator import pytest -from harness_gateway.models import ( +from octop_gateway.models import ( FileContent, InboundMessage, MessageEvent, diff --git a/tests/unit/gateway/test_slash_compact.py b/tests/unit/gateway/test_slash_compact.py index 72b381606b..8fc0883c5d 100644 --- a/tests/unit/gateway/test_slash_compact.py +++ b/tests/unit/gateway/test_slash_compact.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from harness_agent.compaction import CompactResult +from octop_harness.compaction import CompactResult from octop.config import OctopConfig from octop.infra.agents.manager import AgentManager diff --git a/tests/unit/gateway/test_stream_project_tools.py b/tests/unit/gateway/test_stream_project_tools.py index 9d9efc3da2..0c256fa414 100644 --- a/tests/unit/gateway/test_stream_project_tools.py +++ b/tests/unit/gateway/test_stream_project_tools.py @@ -18,7 +18,7 @@ def test_enrich_tool_stream_chunk_ignores_non_tool(): def test_tool_start_event_carries_localized_hint(): - from harness_gateway.models import MessageEvent + from octop_gateway.models import MessageEvent from octop.i18n import channel_tool_hint_start diff --git a/tests/unit/gateway/test_tool_media.py b/tests/unit/gateway/test_tool_media.py index 63961e6859..329e366103 100644 --- a/tests/unit/gateway/test_tool_media.py +++ b/tests/unit/gateway/test_tool_media.py @@ -9,8 +9,8 @@ import pytest from deepagents.backends.local_shell import LocalShellBackend -from harness_agent.backends import resolve_backend -from harness_agent.backends.workspace import BackendWorkspace +from octop_harness.backends import resolve_backend +from octop_harness.backends.workspace import BackendWorkspace from octop.infra.backend.resolver import default_agent_backend_spec from octop.infra.gateway.media.backend_files import ( @@ -414,7 +414,7 @@ def test_dashboard_media_url_uses_path_agent_id() -> None: async def test_resolve_preview_keeps_host_absolute_screenshot() -> None: """Browser screenshots under agent outbound/ must load via absolute path.""" from deepagents.backends.local_shell import LocalShellBackend - from harness_agent.backends.workspace import BackendWorkspace + from octop_harness.backends.workspace import BackendWorkspace from octop.infra.gateway.media.backend_files import resolve_preview_payload diff --git a/tests/unit/gateway/test_versioned_history.py b/tests/unit/gateway/test_versioned_history.py index 17d238435f..f1035e7dfd 100644 --- a/tests/unit/gateway/test_versioned_history.py +++ b/tests/unit/gateway/test_versioned_history.py @@ -459,7 +459,7 @@ async def test_revised_state_message_keeps_latest_tool_metadata(archive): async def test_dashboard_processor_writes_only_new_archive(archive): from unittest.mock import AsyncMock, MagicMock - from harness_gateway.models import ChannelSubject, InboundMessage, TextContent + from octop_gateway.models import ChannelSubject, InboundMessage, TextContent from octop.infra.gateway.process.processor import GlobalProcessor from octop.infra.gateway.slash.dispatcher import SlashDispatcher @@ -623,7 +623,7 @@ def _committed_messages(archive): def _dashboard_with_chunks(archive, chunks): from unittest.mock import AsyncMock, MagicMock - from harness_gateway.models import ChannelSubject, InboundMessage, TextContent + from octop_gateway.models import ChannelSubject, InboundMessage, TextContent from octop.infra.gateway.process.processor import GlobalProcessor from octop.infra.gateway.slash.dispatcher import SlashDispatcher @@ -732,7 +732,7 @@ def fail_after_insert(conn, **kwargs): async def test_dashboard_error_keeps_partial_tokens_and_error(archive): from unittest.mock import AsyncMock, MagicMock - from harness_gateway.models import ChannelSubject, InboundMessage, TextContent + from octop_gateway.models import ChannelSubject, InboundMessage, TextContent from octop.infra.gateway.process.processor import GlobalProcessor from octop.infra.gateway.slash.dispatcher import SlashDispatcher diff --git a/tests/unit/providers/test_opencode_session.py b/tests/unit/providers/test_opencode_session.py index 81ac54109b..8ddc380554 100644 --- a/tests/unit/providers/test_opencode_session.py +++ b/tests/unit/providers/test_opencode_session.py @@ -120,7 +120,7 @@ def _probe_row(**overrides: Any) -> SimpleNamespace: def test_probe_chat_model_injects_throwaway_session_header() -> None: - with patch("harness_agent.llm.factory.build_chat_model") as mock_build: + with patch("octop_harness.llm.factory.build_chat_model") as mock_build: mock_build.return_value = object() build_probe_chat_model(_probe_row(), model_id="glm-5.2") @@ -131,7 +131,7 @@ def test_probe_chat_model_injects_throwaway_session_header() -> None: def test_probe_chat_model_keeps_user_session_header() -> None: row = _probe_row(extra_json=json.dumps({"headers": {OPENCODE_SESSION_HEADER: "mine"}})) - with patch("harness_agent.llm.factory.build_chat_model") as mock_build: + with patch("octop_harness.llm.factory.build_chat_model") as mock_build: mock_build.return_value = object() build_probe_chat_model(row, model_id="glm-5.2") @@ -141,7 +141,7 @@ def test_probe_chat_model_keeps_user_session_header() -> None: def test_probe_injects_for_custom_provider_on_go_url() -> None: row = _probe_row(name="my-relay") - with patch("harness_agent.llm.factory.build_chat_model") as mock_build: + with patch("octop_harness.llm.factory.build_chat_model") as mock_build: mock_build.return_value = object() build_probe_chat_model(row, model_id="glm-5.2") diff --git a/tests/unit/test_bundled_plugins_layout.py b/tests/unit/test_bundled_plugins_layout.py index c4b2c85bab..f802322f3e 100644 --- a/tests/unit/test_bundled_plugins_layout.py +++ b/tests/unit/test_bundled_plugins_layout.py @@ -64,7 +64,7 @@ def test_market_install_copies_from_catalog(tmp_path: Path) -> None: def test_offline_bundled_plugins_load() -> None: - from harness_agent.plugins import PluginRegistry, load_plugin_dir + from octop_harness.plugins import PluginRegistry, load_plugin_dir PluginRegistry.reset() root = default_bundled_plugins_root() diff --git a/tests/unit/test_connectors.py b/tests/unit/test_connectors.py index ae57839832..29be385cc8 100644 --- a/tests/unit/test_connectors.py +++ b/tests/unit/test_connectors.py @@ -1053,7 +1053,7 @@ def test_fliggy_exposes_only_nl_search_tools(): def test_mcp_args_model_drops_nulls_before_validation(): - from harness_agent.mcp import mcp_args_model + from octop_harness.mcp import mcp_args_model model = mcp_args_model( "search_place", diff --git a/tests/unit/test_host_download_allow.py b/tests/unit/test_host_download_allow.py index ee188a4dfa..4add0cf25c 100644 --- a/tests/unit/test_host_download_allow.py +++ b/tests/unit/test_host_download_allow.py @@ -55,6 +55,13 @@ def test_denied_harness_browser(tmp_path: Path) -> None: assert is_allowed_host_download_abs_path(str(path), workspace=tmp_path) is False +def test_denied_octop_browser(tmp_path: Path) -> None: + path = tmp_path / ".octop-browser" / "screenshots" / "x.png" + path.parent.mkdir(parents=True) + path.write_bytes(b"x") + assert is_allowed_host_download_abs_path(str(path), workspace=tmp_path) is False + + def test_file_url_windows_drive_decodes_unicode() -> None: """file:///C:/… must unquote and match native Path form (Windows CI).""" url = "file:///C:/Users/me/out/%E4%BF%9D%E6%8A%A4.pptx" diff --git a/tests/unit/test_host_paths.py b/tests/unit/test_host_paths.py index 1c148ba34a..f84b10defa 100644 --- a/tests/unit/test_host_paths.py +++ b/tests/unit/test_host_paths.py @@ -17,6 +17,7 @@ ("path", "blocked"), [ ("/Users/me/.harness-browser/screenshots/x.png", True), + ("/Users/me/.octop-browser/screenshots/x.png", True), ("/tmp/secret.png", True), ("/home/user/x.png", True), (r"C:\Users\me\screenshot.png", True), diff --git a/tests/unit/test_langfuse_settings.py b/tests/unit/test_langfuse_settings.py index f37cfb965e..8bb4496eff 100644 --- a/tests/unit/test_langfuse_settings.py +++ b/tests/unit/test_langfuse_settings.py @@ -48,7 +48,7 @@ def test_langfuse_requires_secret_when_enabling(store: LangfuseSettingsStore) -> def test_langfuse_harness_config(store: LangfuseSettingsStore) -> None: - from harness_agent.observability.langfuse import LangfuseConfig + from octop_harness.observability.langfuse import LangfuseConfig assert store.harness_config() == LangfuseConfig(enabled=False) store.save( diff --git a/tests/unit/test_plugin_manager.py b/tests/unit/test_plugin_manager.py index 4e3f55eb5c..30379d921a 100644 --- a/tests/unit/test_plugin_manager.py +++ b/tests/unit/test_plugin_manager.py @@ -9,7 +9,7 @@ from typing import Any import pytest -from harness_agent.plugins import PluginRegistry +from octop_harness.plugins import PluginRegistry from octop.infra.agents.plugins.manager import ( PluginManager, diff --git a/tests/unit/test_plugin_seed.py b/tests/unit/test_plugin_seed.py index b765601629..5d7e75b49a 100644 --- a/tests/unit/test_plugin_seed.py +++ b/tests/unit/test_plugin_seed.py @@ -7,7 +7,7 @@ from pathlib import Path import pytest -from harness_agent.plugins import PluginRegistry +from octop_harness.plugins import PluginRegistry from octop.infra.agents.plugins.manager import PluginManager from octop.infra.agents.plugins.seed import seed_bundled_plugins @@ -22,7 +22,7 @@ def _write_plugin(root: Path, plugin_id: str, *, version: str = "0.1.0") -> Path encoding="utf-8", ) (dest / "main.py").write_text( - "from harness_agent.plugins import PluginContext\n" + "from octop_harness.plugins import PluginContext\n" "\n" "def setup(ctx: PluginContext) -> None:\n" f" ctx.tool('{plugin_id}_tool', lambda: 'ok', description='d')\n", diff --git a/tests/unit/test_plugins.py b/tests/unit/test_plugins.py index 1b73e8e0da..1dfba418b8 100644 --- a/tests/unit/test_plugins.py +++ b/tests/unit/test_plugins.py @@ -1,4 +1,4 @@ -"""Unit tests for harness-agent plugin loader.""" +"""Unit tests for octop-harness plugin loader.""" from __future__ import annotations @@ -6,13 +6,13 @@ from typing import Any import pytest -from harness_agent.plugins import ( +from langchain_core.tools import StructuredTool +from octop_harness.plugins import ( PluginRegistry, build_plugin_tools, collect_plugin_tool_configs, load_plugin_dir, ) -from langchain_core.tools import StructuredTool from octop.infra.agents.plugin_tool_names import ( sanitize_plugin_tool_name, @@ -167,8 +167,8 @@ def test_sanitize_plugin_tools_reserved_names() -> None: def test_build_plugin_tools_then_sanitize_keeps_config_keys_original() -> None: pytest.importorskip("pypinyin") - from harness_agent.plugins.manifest import PluginManifest - from harness_agent.plugins.registry import LoadedPlugin, ToolRegistration + from octop_harness.plugins.manifest import PluginManifest + from octop_harness.plugins.registry import LoadedPlugin, ToolRegistration manifest = PluginManifest(id="demo", version="1.0.0", name="Demo", kind="tool", entry="main.py") PluginRegistry().register( diff --git a/tests/unit/test_provider_preset_expansion.py b/tests/unit/test_provider_preset_expansion.py index 7ad08e16d8..5a12402349 100644 --- a/tests/unit/test_provider_preset_expansion.py +++ b/tests/unit/test_provider_preset_expansion.py @@ -23,7 +23,7 @@ def test_load_provider_presets_integration() -> None: token_plan = next(p for p in presets if p["id"] == "tencent-token-plan") token_ids = {m["id"] for m in token_plan["models"]} - # Match by prefix: harness-agent tags token-plan model ids with a release + # Match by prefix: octop-harness tags token-plan model ids with a release # date suffix (e.g. deepseek-v4-flash-202605) and bumps catalog entries # over time, so assert the model family is present rather than an exact id. assert any(mid.startswith("deepseek-v4-flash") for mid in token_ids) diff --git a/tests/unit/test_provider_probe.py b/tests/unit/test_provider_probe.py index 0abaf71a16..8e0ee848f3 100644 --- a/tests/unit/test_provider_probe.py +++ b/tests/unit/test_provider_probe.py @@ -22,7 +22,7 @@ def test_build_chat_model_includes_provider_id_and_model_name() -> None: get_models=lambda: [{"id": "MiniMax-M2.7", "name": "MiniMax-M2.7"}], ) - with patch("harness_agent.llm.factory.build_chat_model") as mock_build: + with patch("octop_harness.llm.factory.build_chat_model") as mock_build: mock_build.return_value = object() _build_chat_model(row, model_id="MiniMax-M2.7") diff --git a/tests/unit/test_security_settings.py b/tests/unit/test_security_settings.py index 185b7eb742..04afba6079 100644 --- a/tests/unit/test_security_settings.py +++ b/tests/unit/test_security_settings.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock -from harness_agent.security.models import SecurityPolicy +from octop_harness.security.models import SecurityPolicy from octop.infra.agents.security import SecuritySettingsStore diff --git a/tests/unit/test_storage_probe.py b/tests/unit/test_storage_probe.py index 1a3dff3fc7..43215ddfae 100644 --- a/tests/unit/test_storage_probe.py +++ b/tests/unit/test_storage_probe.py @@ -98,7 +98,7 @@ def test_docker_probe_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None: docker_mod.from_env = lambda: client monkeypatch.setitem(__import__("sys").modules, "docker", docker_mod) monkeypatch.setattr( - "harness_agent.backends.docker_sandbox.ensure_docker_image", + "octop_harness.backends.docker_sandbox.ensure_docker_image", lambda image, client=None: False, ) @@ -114,7 +114,7 @@ def _resolve(spec: dict[str, Any], workspace_dir: Any = None) -> Any: captured["spec"] = spec return backend - monkeypatch.setattr("harness_agent.backends.resolve_backend", _resolve) + monkeypatch.setattr("octop_harness.backends.resolve_backend", _resolve) result = probe_storage_backend(_row(kind="docker", bucket="python:3.12-slim")) assert result["ok"] is True @@ -135,7 +135,7 @@ def test_docker_probe_user_scope_uses_test_username(monkeypatch: pytest.MonkeyPa docker_mod.from_env = lambda: client monkeypatch.setitem(__import__("sys").modules, "docker", docker_mod) monkeypatch.setattr( - "harness_agent.backends.docker_sandbox.ensure_docker_image", + "octop_harness.backends.docker_sandbox.ensure_docker_image", lambda image, client=None: True, ) @@ -149,7 +149,7 @@ def _resolve(spec: dict[str, Any], workspace_dir: Any = None) -> Any: captured["spec"] = spec return backend - monkeypatch.setattr("harness_agent.backends.resolve_backend", _resolve) + monkeypatch.setattr("octop_harness.backends.resolve_backend", _resolve) result = probe_storage_backend( _row( diff --git a/tests/unit/test_workspace_download.py b/tests/unit/test_workspace_download.py index babed72be5..11dd6e6976 100644 --- a/tests/unit/test_workspace_download.py +++ b/tests/unit/test_workspace_download.py @@ -21,6 +21,11 @@ def test_normalize_download_rejects_harness_browser() -> None: normalize_workspace_download_path("/Users/me/.harness-browser/screenshots/x.png") +def test_normalize_download_rejects_octop_browser() -> None: + with pytest.raises(ValueError): + normalize_workspace_download_path("/Users/me/.octop-browser/screenshots/x.png") + + def test_normalize_download_rejects_windows_users_path() -> None: with pytest.raises(ValueError): normalize_workspace_download_path(r"C:\Users\me\secret.png") diff --git a/tests/unit/trajectory/test_projector.py b/tests/unit/trajectory/test_projector.py index f735a08b9c..de037aa57f 100644 --- a/tests/unit/trajectory/test_projector.py +++ b/tests/unit/trajectory/test_projector.py @@ -7,8 +7,8 @@ Keys: ``ts``, ``role=user``, ``content``, ``thread_id``, ``source``. * ``context_injection.json`` — synthetic; session JSONL has no context rows. Spec §2 ``context`` (source / label / token estimate). ``source=rules`` - matches ``harness_agent.context_usage`` (AGENTS.md / ````). -* ``compacted.json`` — synthetic; matches ``harness_agent.compaction.CompactResult`` + matches ``octop_harness.context_usage`` (AGENTS.md / ````). +* ``compacted.json`` — synthetic; matches ``octop_harness.compaction.CompactResult`` field names (``summarized_count``, ``preserved_count``, ``removed_tokens``) plus spec §2 ``summary`` / ``file_path``. """ diff --git a/tests/unit/trajectory/test_turn_context.py b/tests/unit/trajectory/test_turn_context.py index 928d06daf4..8f8947d812 100644 --- a/tests/unit/trajectory/test_turn_context.py +++ b/tests/unit/trajectory/test_turn_context.py @@ -2,7 +2,7 @@ from __future__ import annotations -from harness_agent.backends.workspace import DEFAULT_MEMORY_FILES +from octop_harness.backends.workspace import DEFAULT_MEMORY_FILES from octop.infra.history.trajectory.turn_context import ( build_turn_start_chunks, diff --git a/uv.lock b/uv.lock index a5c64e12e9..e770462c26 100644 --- a/uv.lock +++ b/uv.lock @@ -1328,55 +1328,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] -[[package]] -name = "harness-browser" -version = "0.7.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "mcp" }, - { name = "pydantic" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a4/08/8dfe724dc05de9ab8c903f64482dbe54f67a9323995867ebbd4094c52fe2/harness_browser-0.7.9.tar.gz", hash = "sha256:a88a7a9d55649c628ff41b073be749885d16d95925521ab8a3e2e4e11c3f37f8", size = 341291, upload-time = "2026-09-17T13:00:12.571Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/29/b652bc07e2bf4b1c012469f512cca9bab01c1958842129304996811e80a1/harness_browser-0.7.9-py3-none-any.whl", hash = "sha256:4c1afc19e225105cbc6f2804a1ae56fd32555b1e32cf8a215752823f79a69023", size = 102702, upload-time = "2026-09-17T13:00:10.986Z" }, -] - -[[package]] -name = "harness-gateway" -version = "0.9.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "cryptography" }, - { name = "dingtalk-stream" }, - { name = "discord-py" }, - { name = "httpx" }, - { name = "lark-oapi" }, - { name = "paho-mqtt" }, - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "python-socketio", extra = ["asyncio-client"] }, - { name = "python-telegram-bot" }, - { name = "websocket-client" }, - { name = "websockets" }, - { name = "wecom-aibot-sdk" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/95/07ccd875cab4cb92c4fcf33948af3feb47ef552279a853e289b615d0fec3/harness_gateway-0.9.9.tar.gz", hash = "sha256:7bf412b3645fd07ee4b9646af2384e770062268426f49778c37dfde558a61788", size = 532057, upload-time = "2026-09-22T04:04:28.177Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/2f/6e38ab8fa06a8ac74b01374a6f79489d695ab42aab25de3e445d2f940e25/harness_gateway-0.9.9-py3-none-any.whl", hash = "sha256:e0021105cece57bce69fa966fea836be5219e124dc74a0c238221703cbb381de", size = 157953, upload-time = "2026-09-22T04:04:25.872Z" }, -] - -[[package]] -name = "harness-memory" -version = "0.9.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/cc/6dbe35a4917e3edf378223e61f03a021ce48be45d6320a260d4a46af0ef6/harness_memory-0.9.11.tar.gz", hash = "sha256:b03dfa7d5543455da858732ab5edcbd7875cbfcb219b0306a56ae920ba4b8e78", size = 1189418, upload-time = "2026-09-20T09:26:41.438Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/82/8a0c5c6624d4bc845449f76823ce424c13802a32629fe4590cc903727d7b/harness_memory-0.9.11-py3-none-any.whl", hash = "sha256:65bb58689c537b6f9adef2b6a19b711dff226833318205ab21b7724fceb93c8f", size = 370616, upload-time = "2026-09-20T09:26:36.915Z" }, -] - [[package]] name = "hf-xet" version = "1.6.0" @@ -2514,16 +2465,16 @@ dependencies = [ { name = "cryptography" }, { name = "edge-tts" }, { name = "fastapi" }, - { name = "harness-browser" }, - { name = "harness-gateway" }, - { name = "harness-memory" }, { name = "josepy" }, { name = "langchain-core" }, { name = "langgraph-checkpoint-postgres" }, { name = "lark-oapi" }, { name = "mcp" }, + { name = "octop-browser" }, + { name = "octop-gateway" }, + { name = "octop-harness", extra = ["all"] }, + { name = "octop-memory" }, { name = "openpyxl" }, - { name = "orcakit-harness-agent", extra = ["all"] }, { name = "pillow" }, { name = "playwright" }, { name = "psycopg", extra = ["binary"] }, @@ -2598,9 +2549,6 @@ requires-dist = [ { name = "edge-tts", specifier = ">=6.1" }, { name = "fastapi", specifier = ">=0.110" }, { name = "fastembed", marker = "extra == 'local-embedding'", specifier = ">=0.4" }, - { name = "harness-browser", specifier = ">=0.7.9" }, - { name = "harness-gateway", specifier = ">=0.9.9" }, - { name = "harness-memory", specifier = ">=0.9.11" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, { name = "huggingface-hub", marker = "extra == 'local-embedding'", specifier = ">=0.20" }, { name = "josepy", specifier = ">=2.2.0" }, @@ -2610,9 +2558,12 @@ requires-dist = [ { name = "mcp", specifier = ">=1.9,<2" }, { name = "mss", marker = "extra == 'desktop'", specifier = ">=9.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, + { name = "octop-browser", specifier = ">=1.0.0" }, + { name = "octop-gateway", specifier = ">=1.0.0" }, + { name = "octop-harness", extras = ["all"], specifier = ">=1.0.0" }, + { name = "octop-memory", specifier = ">=1.0.0" }, { name = "onnxruntime", marker = "extra == 'knowledge-ocr'", specifier = ">=1.17" }, { name = "openpyxl", specifier = ">=3.1" }, - { name = "orcakit-harness-agent", extras = ["all"], specifier = ">=1.0.14" }, { name = "pillow", specifier = ">=10.0" }, { name = "playwright", specifier = ">=1.40" }, { name = "playwright", marker = "extra == 'browser'", specifier = ">=1.40" }, @@ -2657,6 +2608,99 @@ dev = [ { name = "xlwt", specifier = ">=1.3" }, ] +[[package]] +name = "octop-browser" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "mcp" }, + { name = "pydantic" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/a6/a89e5d3c6b27bb5dc310290f52fb85a3d1f59279b7854ebd5c9973855651/octop_browser-1.0.0.tar.gz", hash = "sha256:a7e12bcf7ca1541d6c81e05d88ecb6a3aaf988ee648fe05922d06f94de18210c", size = 455743, upload-time = "2026-09-24T10:14:17.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/2a/16ab16287ea1e3f2357c12ccfe1c90dd0ec7af70d71605c54933c5e7db86/octop_browser-1.0.0-py3-none-any.whl", hash = "sha256:9856d0a3449ff8fb63dc86acb6cf51e492e3d70d77f7f9a35c1802c99566a174", size = 108073, upload-time = "2026-09-24T10:14:15.855Z" }, +] + +[[package]] +name = "octop-gateway" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "cryptography" }, + { name = "dingtalk-stream" }, + { name = "discord-py" }, + { name = "httpx" }, + { name = "lark-oapi" }, + { name = "paho-mqtt" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "python-socketio", extra = ["asyncio-client"] }, + { name = "python-telegram-bot" }, + { name = "websocket-client" }, + { name = "websockets" }, + { name = "wecom-aibot-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/6c/b24f7d8d9f975a7dbe47bcd616429d5a0d75153b2dc0866d6136e653b2db/octop_gateway-1.0.0.tar.gz", hash = "sha256:3630e9f9d40014d961bf8839c19759a676da6e311057df064885fc69cdd789fa", size = 608199, upload-time = "2026-09-24T09:52:46.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/77/c6d42986b1140c148da0f4777bbff31d189179f2bc69ac276392a73f8589/octop_gateway-1.0.0-py3-none-any.whl", hash = "sha256:bb4aeee8b8bbf683f15d29f8adcff9f5c4963544276c56dca858b01eed78ecaa", size = 160304, upload-time = "2026-09-24T09:52:45.299Z" }, +] + +[[package]] +name = "octop-harness" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deepagents" }, + { name = "httpx" }, + { name = "langchain" }, + { name = "langchain-anthropic" }, + { name = "langchain-core" }, + { name = "langchain-mcp-adapters" }, + { name = "langchain-openai" }, + { name = "langgraph" }, + { name = "langgraph-checkpoint-sqlite" }, + { name = "markdownify" }, + { name = "mcp" }, + { name = "octop-browser" }, + { name = "octop-memory" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/84/618b1299758b0ddd5fcf9f6740d154c64ece333bff28e949cdfd335364f0/octop_harness-1.0.0.tar.gz", hash = "sha256:63d049380547723de0756f1c4c307d5844281a098eb1b43fc0c30e8c98a8db2f", size = 931797, upload-time = "2026-09-24T10:13:33.321Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/bc/726ceefa15fb84e54d10a4f078839c94758c680bfd4f2e67f9755e96630f/octop_harness-1.0.0-py3-none-any.whl", hash = "sha256:8448fbb9e5d6884d826a149cd8f58a65f690c1a8f3a0c52bf79da99b8319504e", size = 1133881, upload-time = "2026-09-24T10:13:32.064Z" }, +] + +[package.optional-dependencies] +all = [ + { name = "agent-client-protocol" }, + { name = "cos-python-sdk-v5" }, + { name = "deepagents-backends" }, + { name = "docker" }, + { name = "esdk-obs-python" }, + { name = "google-api-python-client" }, + { name = "langchain-aws" }, + { name = "langchain-community" }, + { name = "langchain-tavily" }, + { name = "langfuse" }, + { name = "mss" }, + { name = "oss2" }, + { name = "pillow" }, + { name = "psutil" }, + { name = "pynput" }, +] + +[[package]] +name = "octop-memory" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/58/cf0333efbdd36808c3b6448f7be846cc4a7b32bb21b0e6f2d4408662aab1/octop_memory-1.0.0.tar.gz", hash = "sha256:1df8fa2deb4d37b20f1d00de6a12c8493dcbdaf01745c8313231395442f79556", size = 577027, upload-time = "2026-09-24T09:53:32.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/30/17736beb07fc5ce734d2568a316f6f159740022c75cd014f63c33014df9d/octop_memory-1.0.0-py3-none-any.whl", hash = "sha256:518d1033d679be00e32d91ad80cf2378a6b7086824fa23927577d316b27e72ce", size = 372264, upload-time = "2026-09-24T09:53:31.44Z" }, +] + [[package]] name = "omegaconf" version = "2.3.1" @@ -2833,50 +2877,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f2/ca/23ba87a221b574a7c5a99d48849d80bfe8b047624681357e2b002e566187/opentelemetry_semantic_conventions-0.64b0-py3-none-any.whl", hash = "sha256:ea77e85e354b8f604ddbe5f3d9135216f982fa4d77e5859ac30f6d8a50505aa6", size = 203713, upload-time = "2026-06-24T15:19:53.339Z" }, ] -[[package]] -name = "orcakit-harness-agent" -version = "1.0.14" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "deepagents" }, - { name = "harness-browser" }, - { name = "harness-memory" }, - { name = "httpx" }, - { name = "langchain" }, - { name = "langchain-anthropic" }, - { name = "langchain-core" }, - { name = "langchain-mcp-adapters" }, - { name = "langchain-openai" }, - { name = "langgraph" }, - { name = "langgraph-checkpoint-sqlite" }, - { name = "markdownify" }, - { name = "mcp" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4d/9b/5a23300805f1187e09a95c9a037a0cf1bf9ed2c45f2068fc1d8e8b88d10d/orcakit_harness_agent-1.0.14.tar.gz", hash = "sha256:a13224912dca14409da3b286e5069c4a8ba17cff79cbef60d8dcbabdbcf438b1", size = 1239932, upload-time = "2026-09-22T09:06:12.653Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/ef/ac2cda738593d7f8be0486303038a8dd026b7aaf2f25cfea5264ba9c55bc/orcakit_harness_agent-1.0.14-py3-none-any.whl", hash = "sha256:7c1a11bc37d68ad1421db196f63718085fa8eab5b952d4fac0f28aa06497867e", size = 1473099, upload-time = "2026-09-22T09:06:07.493Z" }, -] - -[package.optional-dependencies] -all = [ - { name = "agent-client-protocol" }, - { name = "cos-python-sdk-v5" }, - { name = "deepagents-backends" }, - { name = "docker" }, - { name = "esdk-obs-python" }, - { name = "google-api-python-client" }, - { name = "langchain-aws" }, - { name = "langchain-community" }, - { name = "langchain-tavily" }, - { name = "langfuse" }, - { name = "mss" }, - { name = "oss2" }, - { name = "pillow" }, - { name = "psutil" }, - { name = "pynput" }, -] - [[package]] name = "orjson" version = "3.11.9" From 1d2b2558fbc432db797f635ed74d79fd369a70ec Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Fri, 25 Sep 2026 06:09:56 +0000 Subject: [PATCH 016/126] feat(chat): team artifacts, fan-in tool trail, and host wrap-up context Persist member file refs and tool history on team walls, open docks by producer agent, and inject truncated member answers into host follow-up so wrap-up can follow their advice. Co-authored-by: Cursor --- dashboard/src/api/modules/octopThreads.ts | 51 ++- .../src/pages/Chat/ChatFilePreviewContext.tsx | 4 +- .../src/pages/Chat/chatAgentCard.partial.less | 4 +- .../pages/Chat/chatBrowserPanel.partial.less | 18 + .../Chat/components/AssistantTurnView.tsx | 6 +- .../Chat/components/ChatDockFileList.tsx | 85 +++-- .../pages/Chat/components/ChatDockPanel.tsx | 99 ++++-- .../pages/Chat/components/ChatDockPanels.tsx | 7 +- .../pages/Chat/components/MessageFileCard.tsx | 4 +- dashboard/src/pages/Chat/constants.ts | 1 + dashboard/src/pages/Chat/hooks/sseHelpers.ts | 7 + dashboard/src/pages/Chat/hooks/useChat.ts | 34 +- .../pages/Chat/hooks/useChat.usage.test.ts | 15 + .../src/pages/Chat/hooks/useChatDockPanel.ts | 19 +- .../pages/Chat/hooks/useChatFileDetection.ts | 18 +- dashboard/src/pages/Chat/hooks/useSessions.ts | 57 ++-- dashboard/src/pages/Chat/index.tsx | 19 +- .../src/pages/Chat/utils/dockFilePath.test.ts | 24 +- .../src/pages/Chat/utils/dockFilePath.ts | 75 ++++- .../src/pages/Chat/utils/messageContent.ts | 5 +- dashboard/src/utils/collectTurnToolMedia.ts | 3 +- src/octop/api/common/workspace.py | 6 +- src/octop/api/routers/chat/history.py | 14 +- src/octop/api/routers/chat/serialize.py | 18 +- src/octop/api/routers/workspace.py | 7 +- src/octop/i18n/en.json | 2 +- src/octop/i18n/zh.json | 2 +- src/octop/infra/agents/manager.py | 1 + .../agents/middleware/thread_artifacts.py | 267 +++------------ src/octop/infra/agents/teams/team_manager.py | 238 ++++++++++++-- src/octop/infra/agents/thread_artifact.py | 310 ++++++++++++++++++ src/octop/infra/db/repos/threads.py | 81 ++--- src/octop/infra/gateway/threads.py | 10 +- src/octop/infra/utils/thread_artifact.py | 119 +++++++ tests/unit/agents/test_thread_artifacts.py | 127 ++++++- tests/unit/api/test_chat_polish.py | 46 +++ .../gateway/test_global_processor_team.py | 289 +++++++++++++++- 37 files changed, 1615 insertions(+), 477 deletions(-) create mode 100644 src/octop/infra/agents/thread_artifact.py create mode 100644 src/octop/infra/utils/thread_artifact.py diff --git a/dashboard/src/api/modules/octopThreads.ts b/dashboard/src/api/modules/octopThreads.ts index 778ef1b682..5e15685c33 100644 --- a/dashboard/src/api/modules/octopThreads.ts +++ b/dashboard/src/api/modules/octopThreads.ts @@ -1,6 +1,47 @@ import type { HitlPendingPayload, HitlSessionPolicy } from "../types/hitl"; import { request } from "../request"; +/** Workspace file produced in a thread; ``agent_id`` is the producer. */ +export type ThreadArtifact = { + path: string; + agent_id?: string; +}; + +/** + * Prefer ``artifact_refs``; fall back to legacy ``artifacts`` (strings or + * objects) so older servers / clients keep working. + */ +export function normalizeThreadArtifacts( + raw: unknown, + fallbackAgentId?: string | null, + refs?: unknown, +): ThreadArtifact[] { + const source = Array.isArray(refs) && refs.length > 0 ? refs : raw; + if (!Array.isArray(source)) return []; + const out: ThreadArtifact[] = []; + const seen = new Set(); + const fallback = (fallbackAgentId || "").trim(); + for (const item of source) { + let path = ""; + let agentId = fallback; + if (typeof item === "string") { + path = item.trim(); + } else if (item && typeof item === "object") { + const row = item as { path?: unknown; agent_id?: unknown }; + if (typeof row.path === "string") path = row.path.trim(); + if (typeof row.agent_id === "string" && row.agent_id.trim()) { + agentId = row.agent_id.trim(); + } + } + if (!path) continue; + const key = `${agentId}\0${path}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(agentId ? { path, agent_id: agentId } : { path }); + } + return out; +} + export interface OctopThread { thread_id: string; title: string | null; @@ -18,7 +59,10 @@ export interface OctopThread { conversation_mode?: "ask" | "plan" | "craft" | null; pending_plan_path?: string | null; hitl_policy?: HitlSessionPolicy | null; - artifacts?: string[]; + /** Legacy path list (compat). Prefer ``artifact_refs`` when present. */ + artifacts?: Array; + /** Structured refs with producer ``agent_id``. */ + artifact_refs?: ThreadArtifact[]; } export interface OctopThreadHistory { @@ -53,7 +97,10 @@ export interface OctopThreadHistory { turn_active?: boolean; /** Pending tool approval for this thread (survives page reload). */ hitl_pending?: HitlPendingPayload | null; - artifacts?: string[]; + /** Legacy path list (compat). Prefer ``artifact_refs`` when present. */ + artifacts?: Array; + /** Structured refs with producer ``agent_id``. */ + artifact_refs?: ThreadArtifact[]; } export interface OctopThreadPatch { diff --git a/dashboard/src/pages/Chat/ChatFilePreviewContext.tsx b/dashboard/src/pages/Chat/ChatFilePreviewContext.tsx index db84ea7f90..4db5f82127 100644 --- a/dashboard/src/pages/Chat/ChatFilePreviewContext.tsx +++ b/dashboard/src/pages/Chat/ChatFilePreviewContext.tsx @@ -3,7 +3,7 @@ import type { KnowledgeCitation } from "../../utils/parseKnowledgeCitations"; interface ChatFilePreviewContextValue { /** Open the shared file panel on a workspace path (preview / download cards). */ - openFilePreview: (path: string) => void; + openFilePreview: (path: string, agentId?: string | null) => void; /** Open a knowledge citation in the shared chat dock (same shell as file tabs). */ openKnowledgeCitation: (citation: KnowledgeCitation) => void; } @@ -16,7 +16,7 @@ export function ChatFilePreviewProvider({ openKnowledgeCitation, children, }: { - openFilePreview: (path: string) => void; + openFilePreview: (path: string, agentId?: string | null) => void; openKnowledgeCitation: (citation: KnowledgeCitation) => void; children: ReactNode; }) { diff --git a/dashboard/src/pages/Chat/chatAgentCard.partial.less b/dashboard/src/pages/Chat/chatAgentCard.partial.less index 8948b02b0f..cd0652b9e9 100644 --- a/dashboard/src/pages/Chat/chatAgentCard.partial.less +++ b/dashboard/src/pages/Chat/chatAgentCard.partial.less @@ -132,8 +132,8 @@ font-weight: 600; line-height: 1; letter-spacing: 0.02em; - color: #0f766e; - background: color-mix(in srgb, #0d9488 16%, transparent); + color: var(--fn-color-brand); + background: color-mix(in srgb, var(--fn-color-brand) 12%, transparent); white-space: nowrap; } diff --git a/dashboard/src/pages/Chat/chatBrowserPanel.partial.less b/dashboard/src/pages/Chat/chatBrowserPanel.partial.less index b684038bba..b6c75e3e49 100644 --- a/dashboard/src/pages/Chat/chatBrowserPanel.partial.less +++ b/dashboard/src/pages/Chat/chatBrowserPanel.partial.less @@ -469,6 +469,24 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + min-width: 0; +} + +.dockFileTreeFileMeta { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + flex: 1; +} + +.dockFileTreeFileAgent { + font-size: 11px; + font-weight: 400; + color: var(--fn-text-tertiary, #8b8b8f); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .dockFileTreeDownload { diff --git a/dashboard/src/pages/Chat/components/AssistantTurnView.tsx b/dashboard/src/pages/Chat/components/AssistantTurnView.tsx index 3a5eaaadd5..388f588d36 100644 --- a/dashboard/src/pages/Chat/components/AssistantTurnView.tsx +++ b/dashboard/src/pages/Chat/components/AssistantTurnView.tsx @@ -104,8 +104,8 @@ export default function AssistantTurnView({ const fullSplit = useMemo(() => splitAssistantTurn(messages), [messages]); const toolMedia = useMemo( - () => collectTurnToolMedia(fullSplit, agentId), - [fullSplit, agentId], + () => collectTurnToolMedia(fullSplit, speakerAgentId), + [fullSplit, speakerAgentId], ); const knowledgeCitations = useMemo( () => collectTurnKnowledgeCitations(fullSplit), @@ -220,7 +220,7 @@ export default function AssistantTurnView({ images={toolMedia.images} videos={toolMedia.videos} files={toolMedia.files} - agentId={agentId} + agentId={speakerAgentId} />
)} diff --git a/dashboard/src/pages/Chat/components/ChatDockFileList.tsx b/dashboard/src/pages/Chat/components/ChatDockFileList.tsx index 7b580e9b0d..016e9c8ba6 100644 --- a/dashboard/src/pages/Chat/components/ChatDockFileList.tsx +++ b/dashboard/src/pages/Chat/components/ChatDockFileList.tsx @@ -13,14 +13,17 @@ import { listDockFilePathsForTree, mergeDockExpandedFolders, toDockWorkspaceApiPath, + type DockFileRef, type DockPathTreeNode, } from "../utils/dockFilePath"; import styles from "../index.module.less"; interface ChatDockFileListProps { agentId: string; - filePaths: string[]; - onOpenFile: (path: string) => void; + filePaths: Array; + onOpenFile: (path: string, agentId?: string | null) => void; + /** Map producer agent_id → display name (team file dock). */ + agentNameById?: Record; } function FolderRow({ @@ -60,6 +63,7 @@ function FileRow({ onOpen, onDownload, downloadLabel, + agentLabel, }: { node: DockPathTreeNode; depth: number; @@ -67,6 +71,7 @@ function FileRow({ onOpen: () => void; onDownload: () => void; downloadLabel: string; + agentLabel?: string; }) { return (
{fileTreeIcon(node.path, 15)} - - {dockFileBasename(node.path)} + + + {dockFileBasename(node.path)} + + {agentLabel ? ( + {agentLabel} + ) : null} @@ -113,15 +123,21 @@ function TreeNodes({ onOpenFile, onDownload, downloadLabel, + defaultAgentId, + agentNameById, + showAgentLabels, }: { nodes: DockPathTreeNode[]; depth: number; expanded: Set; toggle: (path: string) => void; downloading: string | null; - onOpenFile: (path: string) => void; - onDownload: (path: string) => void; + onOpenFile: (path: string, agentId?: string | null) => void; + onDownload: (path: string, agentId?: string | null) => void; downloadLabel: string; + defaultAgentId: string; + agentNameById?: Record; + showAgentLabels: boolean; }) { return ( <> @@ -146,20 +162,30 @@ function TreeNodes({ onOpenFile={onOpenFile} onDownload={onDownload} downloadLabel={downloadLabel} + defaultAgentId={defaultAgentId} + agentNameById={agentNameById} + showAgentLabels={showAgentLabels} /> ) : null}
); } + const fileAgent = node.agentId || defaultAgentId; + const downloadKey = `${fileAgent}\0${node.path}`; + const agentLabel = + showAgentLabels && fileAgent + ? agentNameById?.[fileAgent] || fileAgent + : undefined; return ( onOpenFile(node.path)} - onDownload={() => onDownload(node.path)} + downloading={downloading === downloadKey} + onOpen={() => onOpenFile(node.path, fileAgent)} + onDownload={() => onDownload(node.path, fileAgent)} downloadLabel={downloadLabel} + agentLabel={agentLabel} /> ); })} @@ -174,16 +200,22 @@ export default function ChatDockFileList({ agentId, filePaths, onOpenFile, + agentNameById, }: ChatDockFileListProps) { const { t } = useTranslation(); - const paths = useMemo( + const refs = useMemo( () => listDockFilePathsForTree(filePaths, agentId), [filePaths, agentId], ); - const tree = useMemo( - () => buildDockPathTree(paths, agentId), - [paths, agentId], - ); + const showAgentLabels = useMemo(() => { + const ids = new Set( + refs.map((ref) => (ref.agentId || agentId || "").trim()).filter(Boolean), + ); + if (ids.size > 1) return true; + const only = [...ids][0]; + return Boolean(only && only !== agentId); + }, [refs, agentId]); + const tree = useMemo(() => buildDockPathTree(refs, agentId), [refs, agentId]); const [expanded, setExpanded] = useState>(() => new Set()); const [downloading, setDownloading] = useState(null); const seenFoldersRef = useRef>(new Set()); @@ -212,15 +244,17 @@ export default function ChatDockFileList({ }, []); const handleDownload = useCallback( - async (path: string) => { - if (!agentId || !path) return; - setDownloading(path); + async (path: string, fileAgentId?: string | null) => { + const owner = (fileAgentId || agentId || "").trim(); + if (!owner || !path) return; + const downloadKey = `${owner}\0${path}`; + setDownloading(downloadKey); try { const blob = await requestBlob( `/agents/${encodeURIComponent( - agentId, + owner, )}/workspace/download?path=${encodeURIComponent( - toDockWorkspaceApiPath(path, agentId), + toDockWorkspaceApiPath(path, owner), )}`, ); const a = document.createElement("a"); @@ -266,7 +300,7 @@ export default function ChatDockFileList({
); - if (paths.length === 0) { + if (refs.length === 0) { return (
{listHint} @@ -291,7 +325,7 @@ export default function ChatDockFileList({
{t("chat.dockFileListCount", { - count: paths.length, + count: refs.length, defaultValue: "{{count}} 个文件", })}
@@ -303,8 +337,11 @@ export default function ChatDockFileList({ toggle={toggle} downloading={downloading} onOpenFile={onOpenFile} - onDownload={(p) => void handleDownload(p)} + onDownload={(p, aid) => void handleDownload(p, aid)} downloadLabel={downloadLabel} + defaultAgentId={agentId} + agentNameById={agentNameById} + showAgentLabels={showAgentLabels} />
diff --git a/dashboard/src/pages/Chat/components/ChatDockPanel.tsx b/dashboard/src/pages/Chat/components/ChatDockPanel.tsx index 8576ad8015..84086f322e 100644 --- a/dashboard/src/pages/Chat/components/ChatDockPanel.tsx +++ b/dashboard/src/pages/Chat/components/ChatDockPanel.tsx @@ -60,12 +60,13 @@ interface ChatDockPanelProps { onClose: () => void; style?: React.CSSProperties; agentId: string; - filePaths: string[]; + filePaths: Array; + agentNameById?: Record; openTabs: DockTab[]; activeTabId: DockTabId | null; onSelectTab: (id: DockTabId) => void; onCloseTab: (id: DockTabId) => void; - onOpenFile: (path: string) => void; + onOpenFile: (path: string, agentId?: string | null) => void; browserEnvironment?: DisplayEnvironment; threadId?: string | null; isStreamingTurn?: boolean; @@ -192,6 +193,7 @@ const ChatDockPanel: React.FC = ({ style, agentId, filePaths, + agentNameById, openTabs, activeTabId, onSelectTab, @@ -215,9 +217,9 @@ const ChatDockPanel: React.FC = ({ const [terminalMounted, setTerminalMounted] = useState( openTabs.some((tab) => tab.kind === "terminal"), ); - const [mountedFilePaths, setMountedFilePaths] = useState(() => - openTabs.filter((tab) => tab.kind === "file").map((tab) => tab.path), - ); + const [mountedFileTabs, setMountedFileTabs] = useState< + Extract[] + >(() => openTabs.filter((tab) => tab.kind === "file")); const [mountedKnowledgeTabs, setMountedKnowledgeTabs] = useState< Extract[] >(() => openTabs.filter((tab) => tab.kind === "knowledge")); @@ -225,7 +227,7 @@ const ChatDockPanel: React.FC = ({ () => openTabs.filter((tab) => tab.kind === "toolUi").map((tab) => tab.callId), ); - const [fileActionsByPath, setFileActionsByPath] = useState< + const [fileActionsById, setFileActionsById] = useState< Record >({}); const [knowledgeActionsById, setKnowledgeActionsById] = useState< @@ -246,8 +248,13 @@ const ChatDockPanel: React.FC = ({ setWorkspaceMounted(hasWorkspace); setBrowserMounted(hasBrowser); setTerminalMounted(hasTerminal); - const openFilePaths = new Set( - openTabs.filter((tab) => tab.kind === "file").map((tab) => tab.path), + const openFileById = new Map( + openTabs + .filter( + (tab): tab is Extract => + tab.kind === "file", + ) + .map((tab) => [tab.id, tab]), ); const openKnowledgeById = new Map( openTabs @@ -260,12 +267,15 @@ const ChatDockPanel: React.FC = ({ const openToolUiCallIds = new Set( openTabs.filter((tab) => tab.kind === "toolUi").map((tab) => tab.callId), ); - setMountedFilePaths((prev) => { - const next = prev.filter((path) => openFilePaths.has(path)); - let changed = next.length !== prev.length; - for (const path of openFilePaths) { - if (!next.includes(path)) { - next.push(path); + setMountedFileTabs((prev) => { + const next = prev + .filter((tab) => openFileById.has(tab.id)) + .map((tab) => openFileById.get(tab.id) ?? tab); + let changed = + next.length !== prev.length || next.some((tab, i) => tab !== prev[i]); + for (const tab of openFileById.values()) { + if (!next.some((row) => row.id === tab.id)) { + next.push(tab); changed = true; } } @@ -296,15 +306,15 @@ const ChatDockPanel: React.FC = ({ } return changed ? next : prev; }); - setFileActionsByPath((prev) => { + setFileActionsById((prev) => { let changed = false; const next: Record = {}; - for (const path of Object.keys(prev)) { - if (openFilePaths.has(path)) { - next[path] = prev[path]; + for (const id of Object.keys(prev)) { + if (openFileById.has(id)) { + next[id] = prev[id]; } else { changed = true; - delete fileActionsHandlersRef.current[path]; + delete fileActionsHandlersRef.current[id]; } } return changed ? next : prev; @@ -328,22 +338,22 @@ const ChatDockPanel: React.FC = ({ browserRefreshRef.current = refresh; }, []); - const getFileActionsHandler = useCallback((path: string) => { - const existing = fileActionsHandlersRef.current[path]; + const getFileActionsHandler = useCallback((tabId: string) => { + const existing = fileActionsHandlersRef.current[tabId]; if (existing) return existing; const handler = (actions: ReactNode | null) => { - setFileActionsByPath((prev) => { + setFileActionsById((prev) => { if (actions == null) { - if (!(path in prev)) return prev; + if (!(tabId in prev)) return prev; const next = { ...prev }; - delete next[path]; + delete next[tabId]; return next; } - if (prev[path] === actions) return prev; - return { ...prev, [path]: actions }; + if (prev[tabId] === actions) return prev; + return { ...prev, [tabId]: actions }; }); }; - fileActionsHandlersRef.current[path] = handler; + fileActionsHandlersRef.current[tabId] = handler; return handler; }, []); @@ -416,7 +426,22 @@ const ChatDockPanel: React.FC = ({ ) : ( <> - {dockFileBasename(tab.path)} + + {dockFileBasename(tab.path)} + {tab.agentId && + tab.agentId !== agentId && + (agentNameById?.[tab.agentId] || tab.agentId) + ? ` · ${agentNameById?.[tab.agentId] || tab.agentId}` + : ""} + ); return ( @@ -478,13 +503,13 @@ const ChatDockPanel: React.FC = ({ ); } if (activeTab?.kind === "file") { - return fileActionsByPath[activeTab.path] ?? null; + return fileActionsById[activeTab.id] ?? null; } if (activeTab?.kind === "knowledge") { return knowledgeActionsById[activeTab.id] ?? null; } return null; - }, [activeTab, fileActionsByPath, knowledgeActionsById, t]); + }, [activeTab, fileActionsById, knowledgeActionsById, t]); return ( = ({
)} - {mountedFilePaths.map((path) => { + {mountedFileTabs.map((tab) => { const isActive = - activeTab?.kind === "file" && activeTab.path === path; + activeTab?.kind === "file" && activeTab.id === tab.id; + const fileAgent = tab.agentId || agentId; return ( ); diff --git a/dashboard/src/pages/Chat/components/ChatDockPanels.tsx b/dashboard/src/pages/Chat/components/ChatDockPanels.tsx index f7aa7acbb2..2a9002b3de 100644 --- a/dashboard/src/pages/Chat/components/ChatDockPanels.tsx +++ b/dashboard/src/pages/Chat/components/ChatDockPanels.tsx @@ -11,12 +11,13 @@ interface ChatDockPanelsProps { isResizing: boolean; panelSizes: { rightWidth: number; bottomHeight: number }; agentId: string; - filePaths: string[]; + filePaths: Array; + agentNameById?: Record; openTabs: DockTab[]; activeTabId: DockTabId | null; onSelectTab: (id: DockTabId) => void; onCloseTab: (id: DockTabId) => void; - onOpenFile: (path: string) => void; + onOpenFile: (path: string, agentId?: string | null) => void; browserEnvironment: DisplayEnvironment; threadId?: string | null; isStreamingTurn?: boolean; @@ -47,6 +48,7 @@ export default function ChatDockPanels({ panelSizes, agentId, filePaths, + agentNameById, openTabs, activeTabId, onSelectTab, @@ -84,6 +86,7 @@ export default function ChatDockPanels({ } agentId={agentId} filePaths={filePaths} + agentNameById={agentNameById} openTabs={openTabs} activeTabId={activeTabId} onSelectTab={onSelectTab} diff --git a/dashboard/src/pages/Chat/components/MessageFileCard.tsx b/dashboard/src/pages/Chat/components/MessageFileCard.tsx index 6ed1ae19d1..e9f122a64a 100644 --- a/dashboard/src/pages/Chat/components/MessageFileCard.tsx +++ b/dashboard/src/pages/Chat/components/MessageFileCard.tsx @@ -71,9 +71,9 @@ export function MessageFileCard({ const openPreview = useCallback(() => { if (previewable && resolvedPath && filePreview) { - filePreview.openFilePreview(resolvedPath); + filePreview.openFilePreview(resolvedPath, agentId); } - }, [previewable, resolvedPath, filePreview]); + }, [previewable, resolvedPath, filePreview, agentId]); return (
diff --git a/dashboard/src/pages/Chat/constants.ts b/dashboard/src/pages/Chat/constants.ts index 5480e6d31e..9192243796 100644 --- a/dashboard/src/pages/Chat/constants.ts +++ b/dashboard/src/pages/Chat/constants.ts @@ -6,6 +6,7 @@ export const FILE_TOOL_NAMES = [ "write_file", "edit_file", "send_file", + "send_file_to_user", ] as const; export const EMPTY_CHAT_SESSION_KEY = "__empty__"; diff --git a/dashboard/src/pages/Chat/hooks/sseHelpers.ts b/dashboard/src/pages/Chat/hooks/sseHelpers.ts index d00eb28843..3bf9b25881 100644 --- a/dashboard/src/pages/Chat/hooks/sseHelpers.ts +++ b/dashboard/src/pages/Chat/hooks/sseHelpers.ts @@ -82,6 +82,13 @@ export interface ChatMessage { speakerAgentId?: string; /** Host wrap-up after members — never continue the dispatch bubble. */ teamWrapup?: boolean; + /** + * Workspace paths written/edited in this turn. Stamped on the final + * (or last file-tool) assistant bubble so the edit-file card still + * shows when process tools are collapsed; full tool trail is also + * persisted for the process panel. + */ + editedFiles?: string[]; } /** 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 b7779c1ca2..66d3448ef3 100644 --- a/dashboard/src/pages/Chat/hooks/useChat.ts +++ b/dashboard/src/pages/Chat/hooks/useChat.ts @@ -11,6 +11,7 @@ import type { CallEntry, } from "../../../api/types"; import type { HitlSessionPolicy } from "../../../api/types/hitl"; +import type { ThreadArtifact } from "../../../api/modules/octopThreads"; import * as chatStore from "./chatStore"; import { shouldBlockHistoryRefresh } from "./wsResumeGate"; import { @@ -473,7 +474,14 @@ function convertCallEntries(entries: CallEntry[]): ChatMessage[] { entry.speaker_agent_id.trim() ? entry.speaker_agent_id.trim() : undefined, - teamWrapup: Boolean((entry as { team_wrapup?: boolean }).team_wrapup), + teamWrapup: Boolean(entry.team_wrapup), + editedFiles: Array.isArray(entry.edited_files) + ? entry.edited_files + .filter( + (p): p is string => typeof p === "string" && p.trim().length > 0, + ) + .map((p) => p.trim()) + : undefined, }; }); @@ -661,6 +669,7 @@ export function convertHistoryMessages( error_code?: string; agent_id?: string; team_wrapup?: boolean; + edited_files?: string[]; }>, agentId?: string, ): ChatMessage[] { @@ -688,6 +697,11 @@ export function convertHistoryMessages( ? message.agent_id.trim() : undefined, team_wrapup: message.team_wrapup === true, + edited_files: Array.isArray(message.edited_files) + ? message.edited_files.filter( + (p): p is string => typeof p === "string" && p.trim().length > 0, + ) + : undefined, }; }); const converted = convertCallEntries(entries).filter( @@ -708,13 +722,12 @@ async function loadThreadHistory( nextOffset: number; nextCursor: string | null; turnActive: boolean; - artifacts: string[]; + artifacts: ThreadArtifact[]; projectionLoading: boolean; retryAfterMs: number; }> { - const { octopThreadsApi, CHAT_HISTORY_PAGE_SIZE } = await import( - "../../../api/modules/octopThreads" - ); + const { octopThreadsApi, CHAT_HISTORY_PAGE_SIZE, normalizeThreadArtifacts } = + await import("../../../api/modules/octopThreads"); const { syncSessionArtifacts, syncSessionConversationMode, @@ -727,12 +740,11 @@ async function loadThreadHistory( offset, cursor: params.cursor, }); - const artifacts = Array.isArray(history.artifacts) - ? history.artifacts.filter( - (path): path is string => - typeof path === "string" && path.trim().length > 0, - ) - : []; + const artifacts = normalizeThreadArtifacts( + history.artifacts, + agentId, + history.artifact_refs, + ); if (offset === 0) { syncSessionArtifacts(threadId, artifacts); syncSessionConversationMode( diff --git a/dashboard/src/pages/Chat/hooks/useChat.usage.test.ts b/dashboard/src/pages/Chat/hooks/useChat.usage.test.ts index 4b0b8c8265..0cd5ca5ff4 100644 --- a/dashboard/src/pages/Chat/hooks/useChat.usage.test.ts +++ b/dashboard/src/pages/Chat/hooks/useChat.usage.test.ts @@ -96,6 +96,21 @@ describe("history token usage", () => { expect(messages[3]?.speakerAgentId).toBe("host"); }); + it("maps stamped edited_files onto history bubbles for the edit card", () => { + const messages = convertHistoryMessages([ + { role: "user", content: "write a plan", id: "u1" }, + { + role: "assistant", + content: "done", + id: "m1", + agent_id: "doctor", + edited_files: ["notes/plan.md", ""], + }, + ]); + expect(messages[1]?.editedFiles).toEqual(["notes/plan.md"]); + expect(messages[1]?.speakerAgentId).toBe("doctor"); + }); + it("maps persisted stream errors to assistant error bubbles", () => { const messages = convertHistoryMessages([ { role: "user", content: "continue this", id: "u1" }, diff --git a/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts b/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts index 44a1257c35..f627346fa2 100644 --- a/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts +++ b/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts @@ -24,7 +24,7 @@ export type DockTab = | { id: "workspace"; kind: "workspace" } | { id: "browser"; kind: "browser" } | { id: "terminal"; kind: "terminal" } - | { id: string; kind: "file"; path: string } + | { id: string; kind: "file"; path: string; agentId?: string } | { id: string; kind: "knowledge"; @@ -162,26 +162,35 @@ export function useChatDockPanel(isMobile: boolean, agentId?: string | null) { }, [openDock]); const openFileAt = useCallback( - (path?: string | null) => { + (path?: string | null, fileAgentId?: string | null) => { if (!path?.trim()) { openFileList(); return; } + const ownerId = (fileAgentId || agentId || "").trim() || null; const hostAbs = normalizeDockFilePath(path); // Keep host-absolute tool paths. Collapsing ``~/.octop/agents//…`` to a // relative key breaks virtual ``root_dir`` nests (bytes live under // ``{root}/Users/…/.octop/agents//…``, not the real agent home). const tabPath = isHostAbsolutePath(hostAbs) ? hostAbs - : canonicalizeDockFilePath(path, agentId); + : canonicalizeDockFilePath(path, ownerId); if (!tabPath) { openFileList(); return; } - const id = dockFileTabId(tabPath, agentId); + const id = dockFileTabId(tabPath, ownerId); setOpenTabs((prev) => { if (prev.some((t) => t.id === id)) return prev; - return [...prev, { id, kind: "file", path: tabPath }]; + return [ + ...prev, + { + id, + kind: "file" as const, + path: tabPath, + ...(ownerId ? { agentId: ownerId } : {}), + }, + ]; }); setActiveTabId(id); openDock(); diff --git a/dashboard/src/pages/Chat/hooks/useChatFileDetection.ts b/dashboard/src/pages/Chat/hooks/useChatFileDetection.ts index a5dd1e6932..be74c06276 100644 --- a/dashboard/src/pages/Chat/hooks/useChatFileDetection.ts +++ b/dashboard/src/pages/Chat/hooks/useChatFileDetection.ts @@ -159,27 +159,31 @@ export function collectChatFilePaths( ): string[] { const paths: string[] = []; for (const m of messages) { - addPath(paths, extractWriteToolPath(m), agentId); + const ownerId = m.speakerAgentId?.trim() || agentId; + addPath(paths, extractWriteToolPath(m), ownerId); + for (const path of m.editedFiles ?? []) { + addPath(paths, path, ownerId); + } for (const att of m.attachments ?? []) { if (att.workspacePath) { - addPath(paths, att.workspacePath, agentId); + addPath(paths, att.workspacePath, ownerId); } else if (att.url) { - addPath(paths, workspacePathFromAccessUrl(att.url) ?? null, agentId); + addPath(paths, workspacePathFromAccessUrl(att.url) ?? null, ownerId); } } const media = collectToolMediaFromToolData( m.toolData, - agentId, + ownerId, m.attachments, ); for (const file of media.files) { - addPath(paths, workspacePathFromAccessUrl(file.url) ?? null, agentId); + addPath(paths, workspacePathFromAccessUrl(file.url) ?? null, ownerId); } for (const img of media.images) { - addPath(paths, workspacePathFromAccessUrl(img.url) ?? null, agentId); + addPath(paths, workspacePathFromAccessUrl(img.url) ?? null, ownerId); } for (const video of media.videos) { - addPath(paths, workspacePathFromAccessUrl(video.url) ?? null, agentId); + addPath(paths, workspacePathFromAccessUrl(video.url) ?? null, ownerId); } } return dedupeDockFilePaths(paths, agentId); diff --git a/dashboard/src/pages/Chat/hooks/useSessions.ts b/dashboard/src/pages/Chat/hooks/useSessions.ts index 24fbba191e..21d3a00d51 100644 --- a/dashboard/src/pages/Chat/hooks/useSessions.ts +++ b/dashboard/src/pages/Chat/hooks/useSessions.ts @@ -1,5 +1,9 @@ import { useCallback, useEffect, useSyncExternalStore } from "react"; -import { octopThreadsApi } from "../../../api/modules/octopThreads"; +import { + normalizeThreadArtifacts, + octopThreadsApi, + type ThreadArtifact, +} from "../../../api/modules/octopThreads"; import type { HitlSessionPolicy } from "../../../api/types/hitl"; import * as chatStore from "./chatStore"; import { onSessionEvent } from "./chatStore"; @@ -21,7 +25,7 @@ export interface Session { conversationMode?: "ask" | "plan" | "craft" | null; pendingPlanPath?: string | null; hitlPolicy?: HitlSessionPolicy | null; - artifacts?: string[]; + artifacts?: ThreadArtifact[]; } /** Result of probing whether a thread exists for the current agent. */ @@ -42,7 +46,9 @@ export function toSession(row: { conversation_mode?: "ask" | "plan" | "craft" | null; pending_plan_path?: string | null; hitl_policy?: HitlSessionPolicy | null; - artifacts?: string[] | null; + artifacts?: Array | null; + artifact_refs?: ThreadArtifact[] | null; + agent_id?: string | null; }): Session { const hasActivity = Boolean(row.has_messages) || Boolean(row.title) || row.last_active > 0; @@ -70,12 +76,11 @@ export function toSession(row: { hitlPolicy: row.hitl_policy ? parseHitlSessionPolicy(row.hitl_policy) : null, - artifacts: Array.isArray(row.artifacts) - ? row.artifacts.filter( - (path): path is string => - typeof path === "string" && path.trim().length > 0, - ) - : [], + artifacts: normalizeThreadArtifacts( + row.artifacts, + row.agent_id, + row.artifact_refs, + ), }; } @@ -173,19 +178,23 @@ export function syncSessionConversationMode( } /** Patch artifacts for one thread in the module session store. */ -export function syncSessionArtifacts(threadId: string, artifacts: string[]) { +export function syncSessionArtifacts( + threadId: string, + artifacts: ThreadArtifact[], +) { if (!threadId) return; - const normalized = artifacts.filter( - (path): path is string => - typeof path === "string" && path.trim().length > 0, - ); + const normalized = normalizeThreadArtifacts(artifacts); setModuleSessions((prev) => { const idx = prev.findIndex((s) => s.id === threadId); if (idx < 0) return prev; const current = prev[idx].artifacts ?? []; if ( current.length === normalized.length && - current.every((p, i) => p === normalized[i]) + current.every( + (p, i) => + p.path === normalized[i]?.path && + (p.agent_id || "") === (normalized[i]?.agent_id || ""), + ) ) { return prev; } @@ -199,19 +208,18 @@ export function syncSessionArtifacts(threadId: string, artifacts: string[]) { export async function fetchAndSyncSessionArtifacts( agentId: string, threadId: string, -): Promise { +): Promise { if (!agentId || !threadId) return []; try { const history = await octopThreadsApi.history(agentId, threadId, { limit: 1, offset: 0, }); - const artifacts = Array.isArray(history.artifacts) - ? history.artifacts.filter( - (path): path is string => - typeof path === "string" && path.trim().length > 0, - ) - : []; + const artifacts = normalizeThreadArtifacts( + history.artifacts, + agentId, + history.artifact_refs, + ); syncSessionArtifacts(threadId, artifacts); return artifacts; } catch { @@ -304,7 +312,9 @@ async function fetchSessionsPage( ): Promise<{ sessions: Session[]; hasMore: boolean }> { const rows = await octopThreadsApi.list(agentId, limit + 1); const hasMore = rows.length > limit; - const sessions = sortSessions(rows.slice(0, limit).map(toSession)); + const sessions = sortSessions( + rows.slice(0, limit).map((row) => toSession({ ...row, agent_id: agentId })), + ); return { sessions, hasMore }; } @@ -539,6 +549,7 @@ export function useSessions(agentId: string | null) { last_active: 0, created_at: now, channel_type: "dashboard", + agent_id: agentId, }); setModuleSessions((prev) => sortSessions([ diff --git a/dashboard/src/pages/Chat/index.tsx b/dashboard/src/pages/Chat/index.tsx index a8bd43d55f..8691116784 100644 --- a/dashboard/src/pages/Chat/index.tsx +++ b/dashboard/src/pages/Chat/index.tsx @@ -496,14 +496,28 @@ function ChatPageInner() { const panelFilePaths = useMemo(() => { const fromTabs = openTabs .filter((tab) => tab.kind === "file") - .map((tab) => tab.path); - const fromThread = composerSession?.artifacts ?? []; + .map((tab) => ({ + path: tab.path, + ...(tab.agentId ? { agentId: tab.agentId } : {}), + })); + const fromThread = (composerSession?.artifacts ?? []).map((item) => ({ + path: item.path, + ...(item.agent_id ? { agentId: item.agent_id } : {}), + })); return listDockFilePathsForTree( [...fromThread, ...fromTabs], resolvedAgentId, ); }, [openTabs, resolvedAgentId, composerSession?.artifacts]); + const dockAgentNameById = useMemo(() => { + const map: Record = {}; + for (const a of agents) { + if (a.agent_id) map[a.agent_id] = a.name || a.agent_id; + } + return map; + }, [agents]); + const { selectedModel, setSelectedModel, @@ -1624,6 +1638,7 @@ function ChatPageInner() { panelSizes={dockPanelSizes} agentId={resolvedAgentId ?? ""} filePaths={sharedExpertViewer ? [] : panelFilePaths} + agentNameById={dockAgentNameById} openTabs={openTabs} activeTabId={activeTabId} onSelectTab={setDockActiveTab} diff --git a/dashboard/src/pages/Chat/utils/dockFilePath.test.ts b/dashboard/src/pages/Chat/utils/dockFilePath.test.ts index f701f024d0..51b1ac3829 100644 --- a/dashboard/src/pages/Chat/utils/dockFilePath.test.ts +++ b/dashboard/src/pages/Chat/utils/dockFilePath.test.ts @@ -128,7 +128,7 @@ describe("dockFilePath", () => { expect(dockFileTabId("file:///workspace/x.py")).toBe("file:x.py"); expect( dockFileTabId("/home/wally/.octop/agents/main/generated/a.pptx", "main"), - ).toBe("file:generated/a.pptx"); + ).toBe("file:main:generated/a.pptx"); }); it("folder tree uses absolute paths from artifacts", () => { @@ -173,7 +173,27 @@ describe("dockFilePath", () => { ["/home/wally/.octop/agents/main/generated/a.pptx", "generated/a.pptx"], "main", ), - ).toEqual(["/home/wally/.octop/agents/main/generated/a.pptx"]); + ).toEqual([ + { + path: "/home/wally/.octop/agents/main/generated/a.pptx", + agentId: "main", + }, + ]); + }); + + it("keeps same relative path from different agents", () => { + expect( + listDockFilePathsForTree( + [ + { path: "outbound/a.md", agentId: "member-a" }, + { path: "outbound/a.md", agentId: "member-b" }, + ], + "host", + ), + ).toEqual([ + { path: "outbound/a.md", agentId: "member-a" }, + { path: "outbound/a.md", agentId: "member-b" }, + ]); }); it("maps download APIs; host abs stays file:// for virtual root failback", () => { diff --git a/dashboard/src/pages/Chat/utils/dockFilePath.ts b/dashboard/src/pages/Chat/utils/dockFilePath.ts index 51a7dc716b..85b6984704 100644 --- a/dashboard/src/pages/Chat/utils/dockFilePath.ts +++ b/dashboard/src/pages/Chat/utils/dockFilePath.ts @@ -101,11 +101,18 @@ export function dockFileBasename(path: string): string { return parts[parts.length - 1] || path; } -/** Stable tab id for an open file path. */ +/** Stable tab id for an open file path (scoped by producer agent when set). */ export function dockFileTabId(path: string, agentId?: string | null): string { - return `file:${canonicalizeDockFilePath(path, agentId)}`; + const key = canonicalizeDockFilePath(path, agentId); + const aid = (agentId || "").trim(); + return aid ? `file:${aid}:${key}` : `file:${key}`; } +export type DockFileRef = { + path: string; + agentId?: string; +}; + /** Prefer a richer on-disk path for tree display after canonical dedupe. */ function preferDisplayPath(current: string, candidate: string): string { const a = current.replace(/\\/g, "/"); @@ -127,34 +134,60 @@ function preferDisplayPath(current: string, candidate: string): string { /** * Deduplicate artifact paths for the dock file list, preferring absolute paths. + * Entries are keyed by ``agentId + path`` so team rooms can list the same + * relative path from multiple members. */ export function listDockFilePathsForTree( - paths: string[], + paths: Array, agentId?: string | null, -): string[] { - return dedupeDockFilePaths(paths, agentId); +): DockFileRef[] { + return dedupeDockFileRefs(paths, agentId); } -/** Deduplicate by canonical workspace path; keep the richest display path. */ +/** @deprecated Prefer ``listDockFilePathsForTree`` (returns refs). */ export function dedupeDockFilePaths( paths: string[], agentId?: string | null, ): string[] { - const bestByKey = new Map(); + return dedupeDockFileRefs(paths, agentId).map((ref) => ref.path); +} + +/** Deduplicate by producer agent + canonical workspace path. */ +export function dedupeDockFileRefs( + paths: Array, + defaultAgentId?: string | null, +): DockFileRef[] { + const bestByKey = new Map(); const order: string[] = []; + const fallback = (defaultAgentId || "").trim(); for (const raw of paths) { - const key = canonicalizeDockFilePath(raw, agentId); - if (!key) continue; - const display = normalizeDockFilePath(raw) || key; + const pathRaw = typeof raw === "string" ? raw : raw.path; + const aid = + (typeof raw === "string" ? fallback : raw.agentId?.trim() || fallback) || + ""; + const keyPath = canonicalizeDockFilePath(pathRaw, aid || defaultAgentId); + if (!keyPath) continue; + const display = normalizeDockFilePath(pathRaw) || keyPath; + const key = `${aid}\0${keyPath}`; const prev = bestByKey.get(key); if (!prev) { - bestByKey.set(key, preferDisplayPath(key, display)); + bestByKey.set(key, { + path: preferDisplayPath(keyPath, display), + ...(aid ? { agentId: aid } : {}), + }); order.push(key); continue; } - bestByKey.set(key, preferDisplayPath(prev, display)); + bestByKey.set(key, { + path: preferDisplayPath(prev.path, display), + ...(aid + ? { agentId: aid } + : prev.agentId + ? { agentId: prev.agentId } + : {}), + }); } - return order.map((key) => bestByKey.get(key) || key); + return order.map((key) => bestByKey.get(key)!); } export type DockPathTreeNode = { @@ -163,6 +196,8 @@ export type DockPathTreeNode = { /** Full path for files; directory prefix for folders. */ path: string; isDir: boolean; + /** Producer agent for file leaves (team room multi-member artifacts). */ + agentId?: string; children: DockPathTreeNode[]; }; @@ -171,13 +206,14 @@ export type DockPathTreeNode = { * chains into ``a / b / c`` labels (PR “Files changed” style). */ export function buildDockPathTree( - paths: string[], + paths: Array, agentId?: string | null, ): DockPathTreeNode[] { type Trie = { name: string; path: string; isDir: boolean; + agentId?: string; children: Map; }; @@ -188,7 +224,9 @@ export function buildDockPathTree( children: new Map(), }; - for (const raw of listDockFilePathsForTree(paths, agentId)) { + for (const ref of listDockFilePathsForTree(paths, agentId)) { + const raw = ref.path; + const fileAgent = (ref.agentId || agentId || "").trim(); const parts = raw.replace(/\\/g, "/").split("/").filter(Boolean); if (parts.length === 0) continue; // Preserve leading slash for absolute paths in the root segment join. @@ -200,15 +238,17 @@ export function buildDockPathTree( const isLast = i === parts.length - 1; acc = acc === "" && abs ? `/${part}` : acc === "" ? part : `${acc}/${part}`; - let child = node.children.get(part); + const childKey = isLast && fileAgent ? `${part}\0${fileAgent}` : part; + let child = node.children.get(childKey); if (!child) { child = { name: part, path: acc, isDir: !isLast, + ...(isLast && fileAgent ? { agentId: fileAgent } : {}), children: new Map(), }; - node.children.set(part, child); + node.children.set(childKey, child); } else if (!isLast) { child.isDir = true; } @@ -235,6 +275,7 @@ export function buildDockPathTree( name: names.filter(Boolean).join(" / "), path: cur.path, isDir: cur.isDir || children.length > 0, + ...(cur.agentId ? { agentId: cur.agentId } : {}), children, }; } diff --git a/dashboard/src/pages/Chat/utils/messageContent.ts b/dashboard/src/pages/Chat/utils/messageContent.ts index c01edc9de7..db6c3c5491 100644 --- a/dashboard/src/pages/Chat/utils/messageContent.ts +++ b/dashboard/src/pages/Chat/utils/messageContent.ts @@ -197,7 +197,10 @@ function messageUsesFileTool(msg: ChatMessage): boolean { /** True when this assistant turn invoked a workspace file write/edit tool. */ export function turnUsedFileTool(split: AssistantTurnSplit): boolean { - return (split?.tools ?? []).some((msg) => messageUsesFileTool(msg)); + if ((split?.tools ?? []).some((msg) => messageUsesFileTool(msg))) { + return true; + } + return (split?.answerMessage?.editedFiles?.length ?? 0) > 0; } /** Index of the most recent assistant turn that invoked a browser tool, or -1. */ diff --git a/dashboard/src/utils/collectTurnToolMedia.ts b/dashboard/src/utils/collectTurnToolMedia.ts index 2221707c1f..4cf6e15401 100644 --- a/dashboard/src/utils/collectTurnToolMedia.ts +++ b/dashboard/src/utils/collectTurnToolMedia.ts @@ -20,9 +20,10 @@ export function collectTurnToolMedia( const files: Array<{ url: string; filename?: string }> = []; for (const msg of split?.tools ?? []) { + const owner = msg.speakerAgentId?.trim() || agentId; const batch = collectToolMediaFromToolData( msg.toolData, - agentId, + owner, msg.attachments, ); images.push(...batch.images); diff --git a/src/octop/api/common/workspace.py b/src/octop/api/common/workspace.py index a4fea72b8b..7f89feb47a 100644 --- a/src/octop/api/common/workspace.py +++ b/src/octop/api/common/workspace.py @@ -73,15 +73,17 @@ async def require_agent_workspace( *, user: Any, server: Any, + as_user: int | None = None, owner_only: bool = False, ) -> BackendWorkspace: """Auth-checked workspace even when the agent is stopped. Used for display files (e.g. expert avatar) that must work from the - experts list without requiring a running harness handle. + experts list without requiring a running harness handle, and for chat + artifact preview/download after a team member has finished a turn. """ checker = require_agent_owner_row if owner_only else require_agent_row - checker(agent_id, user=user, as_user=None, server=server) + checker(agent_id, user=user, as_user=as_user, server=server) assert server.app_runtime is not None workspace = server.app_runtime.agent_registry.workspace_for_agent(agent_id) if workspace is None: diff --git a/src/octop/api/routers/chat/history.py b/src/octop/api/routers/chat/history.py index 5462477adb..7fa3dcfb7d 100644 --- a/src/octop/api/routers/chat/history.py +++ b/src/octop/api/routers/chat/history.py @@ -20,8 +20,8 @@ _load_projected_thread_messages, ) from octop.infra.agents.context_breakdown import SEGMENT_KEYS, compute_context_breakdown -from octop.infra.agents.middleware.thread_artifacts import artifacts_for_response from octop.infra.agents.security.hitl_session import parse_hitl_session_policy +from octop.infra.agents.thread_artifact import thread_artifacts_payload from octop.infra.agents.thread_fork import fork_dashboard_thread from octop.infra.agents.workspace_dir import agent_facing_workspace_dir_from_config from octop.infra.errors import ErrorCode, OctopError @@ -130,7 +130,11 @@ async def list_threads( "conversation_mode": r.conversation_mode or "craft", "pending_plan_path": r.pending_plan_path, "hitl_policy": _hitl_policy_payload(r), - "artifacts": artifacts_for_response(r.artifacts, workspace_dir), + **thread_artifacts_payload( + r.artifacts, + workspace_dir, + default_agent_id=agent_id, + ), } for r in rows ] @@ -383,7 +387,11 @@ async def _work() -> None: "history_retry_after_ms": 1500 if history_loading else 0, "turn_active": server.app_runtime.gateway.ws_hub.is_turn_active(thread_id), "hitl_pending": hitl_pending, - "artifacts": artifacts_for_response(row.artifacts, workspace_dir), + **thread_artifacts_payload( + row.artifacts, + workspace_dir, + default_agent_id=agent_id, + ), "conversation_mode": row.conversation_mode or "craft", "pending_plan_path": row.pending_plan_path, "hitl_policy": _hitl_policy_payload(row), diff --git a/src/octop/api/routers/chat/serialize.py b/src/octop/api/routers/chat/serialize.py index 6a17edcd6e..08e95e291c 100644 --- a/src/octop/api/routers/chat/serialize.py +++ b/src/octop/api/routers/chat/serialize.py @@ -547,7 +547,11 @@ def _enrich_history_tool_media( *, agent_id: str, ) -> list[dict[str, Any]]: - """Attach preview URLs for tool media without disk I/O.""" + """Attach preview URLs for tool media without disk I/O. + + Prefer each entry's ``agent_id`` (team speaker) so member tool media + resolves against the producer workspace, not the room host. + """ from octop.infra.gateway.media.tool_media import enrich_tool_output_string_sync # noqa: PLC0415 enriched: list[dict[str, Any]] = [] @@ -556,6 +560,8 @@ def _enrich_history_tool_media( if not isinstance(content, list): enriched.append(entry) continue + owner = entry.get("agent_id") + media_agent = owner.strip() if isinstance(owner, str) and owner.strip() else agent_id blocks: list[Any] = [] changed = False for block in content: @@ -566,7 +572,7 @@ def _enrich_history_tool_media( if not isinstance(output, str) or not output.strip(): blocks.append(block) continue - new_output = enrich_tool_output_string_sync(output, agent_id=agent_id) + new_output = enrich_tool_output_string_sync(output, agent_id=media_agent) if new_output != output: blocks.append({**block, "output": new_output}) changed = True @@ -946,6 +952,9 @@ def _serialize_history_message( entry: dict[str, Any] = {"role": "tool", "content": blocks} if mid: entry["id"] = mid + speaker = additional_kwargs.get("speaker_agent_id") + if isinstance(speaker, str) and speaker.strip(): + entry["agent_id"] = speaker.strip() return _apply_history_timestamp(entry, msg, fallback_created_at) content = _msg_attr(msg, "content", "") @@ -1010,6 +1019,11 @@ def _serialize_history_message( entry["agent_id"] = speaker.strip() if additional_kwargs.get("team_wrapup"): entry["team_wrapup"] = True + edited = additional_kwargs.get("edited_files") + if isinstance(edited, list): + cleaned = [path.strip() for path in edited if isinstance(path, str) and path.strip()] + if cleaned: + entry["edited_files"] = cleaned return _apply_history_timestamp(entry, msg, fallback_created_at) diff --git a/src/octop/api/routers/workspace.py b/src/octop/api/routers/workspace.py index fd9db8c485..3fd5daba76 100644 --- a/src/octop/api/routers/workspace.py +++ b/src/octop/api/routers/workspace.py @@ -17,6 +17,7 @@ coerce_read_content, file_info_to_dict, reanchor_entry_path, + require_agent_workspace, require_running_workspace, workspace_api_path, ) @@ -161,7 +162,7 @@ async def read_file( server: Any = Depends(get_server), ) -> dict[str, Any]: """Read a UTF-8 text file.""" - ws = await require_running_workspace(agent_id, user=user, as_user=as_user, server=server) + ws = await require_agent_workspace(agent_id, user=user, as_user=as_user, server=server) content = await ws.aread_text(_workspace_io_path(path, from_workspace=from_workspace)) if content is None: raise OctopError(ErrorCode.NOT_FOUND, f"cannot read {path!r}") @@ -341,7 +342,7 @@ async def download_file( outputs (Desktop, ``~/.octop/agents/…``, workspace tree) but denied for sensitive system roots (``/etc``, ``.harness-browser``, ``.octop-browser``, Windows system dirs). """ - ws = await require_running_workspace(agent_id, user=user, as_user=as_user, server=server) + ws = await require_agent_workspace(agent_id, user=user, as_user=as_user, server=server) io_path = _workspace_io_path(path, from_workspace=from_workspace) if is_host_absolute_path(io_path) and not is_allowed_host_download_abs_path( io_path, @@ -375,7 +376,7 @@ async def read_doc( ) -> dict[str, Any]: """Read an editable document (e.g. ``.docx``) as Markdown for online editing.""" converter = _ensure_editable_doc(path) - ws = await require_running_workspace(agent_id, user=user, as_user=as_user, server=server) + ws = await require_agent_workspace(agent_id, user=user, as_user=as_user, server=server) io_path = _workspace_io_path(path, from_workspace=from_workspace) try: blob = await ws.adownload_bytes(io_path) diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index 8e30cd3ec9..d88626d5c5 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -797,7 +797,7 @@ }, "teams": { "peer_thread_title": "From team {name}", - "followup_done": "[system wake-up, not a new user question]\nTeam member {name} finished. Their answer is already on the group timeline.\nTask: {task}\n\nDo not restate their answer. Do not say you have assigned someone or ask the user to wait. Do not call ask_agent for the same task again. If more work is needed, dispatch the next member. If the team is done, write at most three short wrap-up sentences.", + "followup_done": "[system wake-up, not a new user question]\nTeam member {name} finished. Their answer is already on the group timeline; it is not in your own chat context, so a truncated excerpt is included below for wrap-up.\nTask: {task}\n\nMember answer:\n{result}\n\nUse that answer to decide next steps. Do not paste it back in full. Do not say you have assigned someone or ask the user to wait. Do not call ask_agent for the same task again. If more work is needed, dispatch the next member. If the team is done, write at most three short wrap-up sentences and say whether you adopt their advice.", "followup_failed": "[system wake-up, not a new user question]\nTeam member {name} failed.\nTask: {task}\nError: {error}\n\nBriefly tell the user it failed and either dispatch someone else or close the turn. Do not announce another assignment. Do not invent a substitute answer.", "speaker_user": "User", "channel_line": "{name}: {text}", diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index 1ed9037dae..01df6dcd15 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -797,7 +797,7 @@ }, "teams": { "peer_thread_title": "来自团队 {name}", - "followup_done": "[系统唤醒,不是新的用户问题]\n团队成员 {name} 已完成。对方正文已经出现在群聊时间线上。\n任务:{task}\n\n不要复述成员全文。不要再说「已安排 / 稍等片刻 / 交给谁来回答」,不要再次 ask_agent 同一任务。若还要继续,再派下一位;若可以收工,最多三句短总结。", + "followup_done": "[系统唤醒,不是新的用户问题]\n团队成员 {name} 已完成。对方正文已在群聊时间线上;你自己的对话上下文里没有这段,下面附带节选供收口参考。\n任务:{task}\n\n成员正文:\n{result}\n\n请依据成员正文决定下一步:不要整段复述,不要再说「已安排 / 稍等片刻 / 交给谁来回答」,不要再次 ask_agent 同一任务。若还要继续,再派下一位;若可以收工,最多三句短总结,并明确是否采纳其建议。", "followup_failed": "[系统唤醒,不是新的用户问题]\n团队成员 {name} 失败。\n任务:{task}\n错误:{error}\n\n简要告知用户失败,并决定改派别人或收工。不要再说「已安排 / 稍等」,不要编造替代答案。", "speaker_user": "用户", "channel_line": "{name}: {text}", diff --git a/src/octop/infra/agents/manager.py b/src/octop/infra/agents/manager.py index 92436fbfa7..00b0631417 100644 --- a/src/octop/infra/agents/manager.py +++ b/src/octop/infra/agents/manager.py @@ -3128,6 +3128,7 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: ThreadArtifactsMiddleware( thread_repo=self._repos.thread_repo, workspace_dir=harness_workspace, + agent_id=row.agent_id, ), ] diff --git a/src/octop/infra/agents/middleware/thread_artifacts.py b/src/octop/infra/agents/middleware/thread_artifacts.py index 17d4566dc8..fb8d5a2d26 100644 --- a/src/octop/infra/agents/middleware/thread_artifacts.py +++ b/src/octop/infra/agents/middleware/thread_artifacts.py @@ -2,10 +2,7 @@ from __future__ import annotations -import json import logging -import os -import re from collections.abc import Awaitable, Callable, Mapping, Sequence from pathlib import Path from typing import Any, Protocol @@ -16,134 +13,31 @@ from langgraph.prebuilt.tool_node import ToolCallRequest from langgraph.types import Command -from octop.infra.gateway.media.backend_files import ( - extract_workspace_rel, - file_url_to_abs_path, - is_host_absolute_path, +from octop.infra.agents.thread_artifact import ( + extract_artifact_paths, + is_artifact_tool_name, ) -from octop.infra.gateway.media.tool_media import tool_name_base logger = logging.getLogger(__name__) -ARTIFACT_TOOL_BASES = frozenset( - { - "write_file", - "edit_file", - "send_file", - "send_file_to_user", - "desktop_screenshot", - "mobile_screenshot", - } -) - -_PATH_KEYS = ( - "path", - "file_path", - "filepath", - "dest", - "target_path", - "output_path", -) - -_PATH_EXT_RE = re.compile(r"\.[A-Za-z][A-Za-z0-9._+-]{0,11}$") -_ABS_OCTOP_RE = re.compile(r"(?:/[\w.-]+)*/\.octop/agents/[^\s\"'<>]+", re.IGNORECASE) -_REL_DIR_RE = re.compile( - r"(?:^|[\s\"'`])((?:outbound|inbound|generated)/[^\s\"'<>]+)", - re.IGNORECASE, -) - class ArtifactThreadStore(Protocol): - def append_artifacts(self, thread_id: str, paths: Sequence[str]) -> None: ... - - -def is_artifact_tool_name(name: str | None) -> bool: - base = tool_name_base(name or "").lower() - return base in ARTIFACT_TOOL_BASES - - -def _artifact_path_allowed(posix: str) -> bool: - if not posix or posix in {".", "/"}: - return False - if "/_builtin_skills/" in f"/{posix}/" or posix.lstrip("/").startswith("_builtin_skills"): - return False - base = posix.rstrip("/").split("/")[-1] or posix - if re.fullmatch(r"[\d.]+", base): - return False - return bool(_PATH_EXT_RE.search(base)) - - -def _coerce_raw_path(path: str) -> str: - raw = (path or "").strip().replace("\\", "/") - if not raw: - return "" - if raw.startswith("file://"): - return file_url_to_abs_path(raw).replace("\\", "/") - return raw - + def append_artifacts( + self, + thread_id: str, + paths: Sequence[str], + *, + agent_id: str = "", + ) -> None: ... -def normalize_artifact_path(path: str, workspace_dir: Path) -> str: - """Return a path for ``threads.artifacts``, or ``\"\"``. - Absolute paths are stored as-is. Relative paths are joined under - ``workspace_dir`` (the agent work area — not backend ``root_dir``). - """ - raw = _coerce_raw_path(path) - if not raw or not _artifact_path_allowed(raw): - return "" - if is_host_absolute_path(raw): - return raw - rel = extract_workspace_rel(raw) or raw.lstrip("/") - if rel.startswith("workspace/"): - rel = rel.removeprefix("workspace/") - if not rel or not _artifact_path_allowed(rel): +def peer_room_thread_id(thread_id: str) -> str: + """Caller room id for a peer thread (``room~callee``), else ``\"\"``.""" + tid = (thread_id or "").strip() + if "~" not in tid: return "" - # Keep the configured workspace spelling stable. On macOS, ``/home`` is a - # firmlink and ``Path.resolve()`` rewrites it to ``/System/Volumes/Data/home``, - # which breaks artifact de-duplication against already-absolute entries. - ws = workspace_dir.expanduser() - return str((ws / rel).as_posix()) - - -def artifacts_for_response(paths: Sequence[str], workspace_dir: Path) -> list[str]: - """Normalize stored artifact paths for API responses. - - Absolute entries pass through; legacy relative entries are joined to - ``workspace_dir``. - """ - out: list[str] = [] - seen: set[str] = set() - for raw in paths: - resolved = normalize_artifact_path(raw, workspace_dir) - if not resolved: - continue - key = resolved.casefold() if os.name == "nt" else resolved - if key in seen: - continue - seen.add(key) - out.append(resolved) - return out - - -def extract_artifact_paths( - *, - tool_name: str = "", - args: str | Mapping[str, Any] | None = None, - result: Any = None, - workspace_dir: Path | None = None, -) -> list[str]: - """Return workspace paths from a write/edit/send/screenshot tool. - - Prefer structured path keys on tool *args*. Scan the tool result text only - when args did not yield a path (screenshots, send_file without a path key). - """ - if not is_artifact_tool_name(tool_name): - return [] - ws = workspace_dir.expanduser() if workspace_dir is not None else None - from_args = _dedupe_paths(_paths_from_args(args), ws) - if from_args: - return from_args - return _dedupe_paths(_paths_from_content(result), ws) + room = tid.rsplit("~", 1)[0].strip() + return room if room and room != tid else "" def current_thread_id() -> str: @@ -158,17 +52,24 @@ def current_thread_id() -> str: class ThreadArtifactsMiddleware(AgentMiddleware[Any, Any]): - """Append successful file-producing tool paths onto the current thread row.""" + """Append successful file-producing tool paths onto the current thread row. + + Peer threads (``room~member``) also mirror the same paths onto the room + thread, stamped with the producer ``agent_id`` so the team file dock can + open them via the member workspace. + """ def __init__( self, *, thread_repo: ArtifactThreadStore, workspace_dir: Path, + agent_id: str = "", ) -> None: super().__init__() self._threads = thread_repo self._workspace_dir = workspace_dir.expanduser() + self._agent_id = (agent_id or "").strip() def wrap_tool_call( self, @@ -215,15 +116,25 @@ def _record( ) if not paths: return + self._append(thread_id, paths) + room = peer_room_thread_id(thread_id) + if room: + self._append(room, paths) + self._maybe_record_pending_plan(thread_id, paths) + + def _append(self, thread_id: str, paths: Sequence[str]) -> None: try: - self._threads.append_artifacts(thread_id, paths) + self._threads.append_artifacts( + thread_id, + paths, + agent_id=self._agent_id, + ) except Exception: logger.warning( "Failed to append thread artifacts for %s", thread_id, exc_info=True, ) - self._maybe_record_pending_plan(thread_id, paths) def _maybe_record_pending_plan(self, thread_id: str, paths: Sequence[str]) -> None: from octop.infra.agents.conversation_mode import ( # noqa: PLC0415 @@ -258,111 +169,9 @@ def _maybe_record_pending_plan(self, thread_id: str, paths: Sequence[str]) -> No ) -def _dedupe_paths(paths: Sequence[str], workspace_dir: Path | None) -> list[str]: - out: list[str] = [] - seen: set[str] = set() - for raw in paths: - if workspace_dir is None: - key = _coerce_raw_path(raw) - if not key or not _artifact_path_allowed(key) or key in seen: - continue - seen.add(key) - out.append(key) - continue - resolved = normalize_artifact_path(raw, workspace_dir) - if not resolved: - continue - dedupe_key = resolved.casefold() if os.name == "nt" else resolved - if dedupe_key in seen: - continue - seen.add(dedupe_key) - out.append(resolved) - return out - - -def _pick_path_from_object(parsed: Mapping[str, Any]) -> str: - for key in _PATH_KEYS: - value = parsed.get(key) - if isinstance(value, str) and value.strip(): - return value.strip() - return "" - - -def _paths_from_args(raw: str | Mapping[str, Any] | None) -> list[str]: - if raw is None: - return [] - if isinstance(raw, Mapping): - picked = _pick_path_from_object(raw) - return [picked] if picked else [] - text = raw.strip() - if not text: - return [] - try: - parsed = json.loads(text) - except (ValueError, TypeError): - parsed = None - if isinstance(parsed, dict): - picked = _pick_path_from_object(parsed) - return [picked] if picked else _paths_from_text(text) - return _paths_from_text(text) - - -def _paths_from_content(content: Any) -> list[str]: - if content is None: - return [] - if isinstance(content, str): - stripped = content.strip() - if stripped and stripped[0] in "{[": - try: - parsed = json.loads(stripped) - except (ValueError, TypeError): - parsed = None - if isinstance(parsed, dict): - return _paths_from_content(parsed) - if isinstance(parsed, list): - found: list[str] = [] - for item in parsed: - found.extend(_paths_from_content(item)) - return found - return _paths_from_text(stripped) - if isinstance(content, Mapping): - found = [] - picked = _pick_path_from_object(content) - if picked: - found.append(picked) - nested = content.get("source") - if isinstance(nested, Mapping): - url = nested.get("url") - if isinstance(url, str) and url.strip(): - found.append(url.strip()) - return found - if isinstance(content, list): - found = [] - for item in content: - found.extend(_paths_from_content(item)) - return found - return [] - - -def _paths_from_text(text: str) -> list[str]: - if not text: - return [] - abs_match = _ABS_OCTOP_RE.search(text) - if abs_match: - return [abs_match.group(0)] - rel_match = _REL_DIR_RE.search(text) - if rel_match: - return [rel_match.group(1)] - return [] - - __all__ = [ - "ARTIFACT_TOOL_BASES", + "ArtifactThreadStore", "ThreadArtifactsMiddleware", - "artifacts_for_response", "current_thread_id", - "extract_artifact_paths", - "is_artifact_tool_name", - "normalize_artifact_path", - "tool_name_base", + "peer_room_thread_id", ] diff --git a/src/octop/infra/agents/teams/team_manager.py b/src/octop/infra/agents/teams/team_manager.py index 057bbc69f2..461e9f546e 100644 --- a/src/octop/infra/agents/teams/team_manager.py +++ b/src/octop/infra/agents/teams/team_manager.py @@ -31,6 +31,8 @@ from octop.i18n import tr from octop.infra.agents.teams.service import is_team_agent +from octop.infra.agents.thread_artifact import artifact_path_allowed, extract_artifact_paths +from octop.infra.gateway.media.tool_media import tool_name_base from octop.infra.gateway.process.history_projection import ( live_message_inputs, message_inputs, @@ -38,6 +40,22 @@ from octop.infra.utils.locale import DEFAULT_LOCALE, resolve_user_locale from octop.infra.utils.ulid import new_ulid +# Matches dashboard FILE_TOOL_NAMES — drives the "edited N files" history card. +_EDIT_CARD_TOOL_BASES = frozenset( + { + "write_file", + "edit_file", + "send_file", + "send_file_to_user", + } +) + +# Cap tool-result bodies on room/member projections so fan-in history stays lean. +_HISTORY_TOOL_RESULT_MAX_CHARS = 4000 +_HISTORY_TOOL_RESULT_SUFFIX = "…" +# Member answer excerpt injected into the host wrap-up wake-up prompt. +_FOLLOWUP_RESULT_MAX_CHARS = 6000 + if TYPE_CHECKING: from octop.infra.agents.manager import AgentManager from octop.infra.db.repos.sessions import SessionRow @@ -180,8 +198,11 @@ async def record_peer_turn( if self._thread_message_repo is None: return messages = result.get("messages") - visible = _peer_turn_messages(messages) if isinstance(messages, list) else [] + msg_list = messages if isinstance(messages, list) else [] + edited = _edited_files_from_messages(msg_list) + visible = _peer_turn_messages(msg_list) visible = [msg for msg in visible if _message_role(msg) not in {"human", "user"}] + visible, _ = _apply_edited_files_stamp(visible, edited) prompt = await self._assignment_text(call) if prompt: visible = [ @@ -197,7 +218,10 @@ async def record_peer_turn( try: self._thread_message_repo.append_if_ready( thread_id, - message_inputs(visible, dedupe_missing_ids=True), + message_inputs( + _history_projection_messages(visible), + dedupe_missing_ids=True, + ), ) except Exception: logger.warning( @@ -220,7 +244,6 @@ def compose_followup( result_text: str | None, error_text: str | None, ) -> str: - _ = result_text child_name = self._display_name(msg.target_agent_id) locale = self._locale_for(_octop_user_id(msg.user_id)) if error_text: @@ -231,11 +254,20 @@ def compose_followup( task=msg.message, error=error_text, ) + # Member answers live on the room wall / peer checkpoint, not the host + # graph — inject a truncated excerpt so wrap-up can follow their advice. + result = _truncate_tool_result_text( + (result_text or "").strip(), + max_chars=_FOLLOWUP_RESULT_MAX_CHARS, + ) + if not result: + result = "(empty)" return tr( "teams.followup_done", locale, name=child_name, task=msg.message, + result=result, ) async def on_reply(self, event: ReplyEvent) -> None: @@ -421,19 +453,23 @@ async def fan_in_peer_turn( *, live_streamed: bool = False, ) -> None: - """Persist the member's final bubble on the caller's room thread.""" + """Persist the member's tool trail + final bubble on the caller's room thread.""" if not self._is_group_dispatch(call): return speaker = call.to_agent_id stamped = [_with_speaker(msg, speaker) for msg in _room_peer_messages(visible)] - final = _final_room_assistant(stamped) - if final is None: + if not stamped: return + edited = _edited_files_from_messages(stamped) + stamped, final = _apply_edited_files_stamp(stamped, edited) if self._thread_message_repo is not None: try: self._thread_message_repo.append_if_ready( call.source_thread_id, - live_message_inputs([final], dedupe_missing_ids=True), + live_message_inputs( + _history_projection_messages(stamped), + dedupe_missing_ids=True, + ), ) except Exception: logger.warning( @@ -443,8 +479,8 @@ async def fan_in_peer_turn( exc_info=True, ) room = str(call.source_thread_id or "").strip() - text = _assistant_text(final) - if not room: + text = _assistant_text(final) if final is not None else "" + if not room or not text: return await self._push_room_to_channels(room, speaker, text) if not live_streamed: @@ -1192,19 +1228,137 @@ def _final_room_assistant(messages: list[Any]) -> Any | None: return None -def _with_speaker(msg: Any, agent_id: str) -> Any: +def _apply_edited_files_stamp( + messages: list[Any], + edited: list[str], +) -> tuple[list[Any], Any | None]: + """Stamp ``edited_files`` on the final text bubble, or last AI if tools-only.""" + final = _final_room_assistant(messages) + if not edited: + return messages, final + target = final + if target is None: + for msg in reversed(messages): + if _message_is_assistant(msg): + target = msg + break + if target is None: + return messages, final + stamped_target = _with_edited_files(target, edited) + out = [stamped_target if msg is target else msg for msg in messages] + if final is None: + return out, None + return out, stamped_target if final is target else final + + +def _truncate_tool_result_text( + text: str, + *, + max_chars: int = _HISTORY_TOOL_RESULT_MAX_CHARS, +) -> str: + if len(text) <= max_chars: + return text + keep = max(max_chars - len(_HISTORY_TOOL_RESULT_SUFFIX), 0) + return text[:keep] + _HISTORY_TOOL_RESULT_SUFFIX + + +def _truncate_tool_content(content: Any) -> Any: + if isinstance(content, str): + return _truncate_tool_result_text(content) + if isinstance(content, list): + out: list[Any] = [] + changed = False + for block in content: + if isinstance(block, str): + new_block = _truncate_tool_result_text(block) + out.append(new_block) + changed = changed or new_block != block + elif isinstance(block, dict) and isinstance(block.get("text"), str): + text = str(block["text"]) + new_text = _truncate_tool_result_text(text) + if new_text != text: + out.append({**block, "text": new_text}) + changed = True + else: + out.append(block) + else: + out.append(block) + return out if changed else content + return content + + +def _replace_message_content(msg: Any, content: Any) -> Any: + if isinstance(msg, dict): + out = dict(msg) + out["content"] = content + return out + copy = getattr(msg, "model_copy", None) + if callable(copy): + try: + return copy(update={"content": content}) + except Exception: + pass + try: + object.__setattr__(msg, "content", content) + except Exception: + msg.content = content + return msg + + +def _history_projection_messages(messages: list[Any]) -> list[Any]: + """Truncate bulky ToolMessage bodies before writing team projections.""" + out: list[Any] = [] + for msg in messages: + if _message_role(msg) != "tool": + out.append(msg) + continue + content = msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", "") + truncated = _truncate_tool_content(content) + if truncated is content: + out.append(msg) + continue + out.append(_replace_message_content(msg, truncated)) + return out + + +def _edited_files_from_messages(messages: list[Any]) -> list[str]: + """Workspace paths from write/edit/send tool_calls in a peer turn.""" + out: list[str] = [] + seen: set[str] = set() + for msg in messages: + tool_calls = _message_tool_calls(msg) + if not isinstance(tool_calls, list): + continue + for call in tool_calls: + if isinstance(call, dict): + name = str(call.get("name") or "") + args = call.get("args") + else: + name = str(getattr(call, "name", "") or "") + args = getattr(call, "args", None) + base = tool_name_base(name).lower() + if base not in _EDIT_CARD_TOOL_BASES: + continue + for path in extract_artifact_paths(tool_name=name, args=args): + key = path.replace("\\", "/").strip() + if not key or not artifact_path_allowed(key) or key in seen: + continue + seen.add(key) + out.append(key) + return out + + +def _patch_additional_kwargs(msg: Any, updates: dict[str, Any]) -> Any: + if not updates: + return msg if isinstance(msg, dict): kwargs = dict(msg.get("additional_kwargs") or {}) - if kwargs.get("speaker_agent_id") == agent_id: - return msg - kwargs["speaker_agent_id"] = agent_id + kwargs.update(updates) out = dict(msg) out["additional_kwargs"] = kwargs return out kwargs = dict(getattr(msg, "additional_kwargs", None) or {}) - if kwargs.get("speaker_agent_id") == agent_id: - return msg - kwargs["speaker_agent_id"] = agent_id + kwargs.update(updates) copy = getattr(msg, "model_copy", None) if callable(copy): try: @@ -1218,6 +1372,24 @@ def _with_speaker(msg: Any, agent_id: str) -> Any: return msg +def _with_speaker(msg: Any, agent_id: str) -> Any: + if isinstance(msg, dict): + kwargs = dict(msg.get("additional_kwargs") or {}) + if kwargs.get("speaker_agent_id") == agent_id: + return msg + else: + kwargs = dict(getattr(msg, "additional_kwargs", None) or {}) + if kwargs.get("speaker_agent_id") == agent_id: + return msg + return _patch_additional_kwargs(msg, {"speaker_agent_id": agent_id}) + + +def _with_edited_files(msg: Any, paths: list[str]) -> Any: + if not paths: + return msg + return _patch_additional_kwargs(msg, {"edited_files": list(paths)}) + + def _ensure_reasoning(msg: Any, reasoning: str) -> None: if not reasoning: return @@ -1263,25 +1435,27 @@ def _assistant_text(msg: Any) -> str: def _peer_turn_messages(messages: list[Any]) -> list[Any]: - trigger: Any | None = None - final_ai: Any | None = None - for msg in messages: - role = "" - if isinstance(msg, dict): - role = str(msg.get("role") or msg.get("type") or "").lower() - tool_calls = msg.get("tool_calls") - else: - role = str(getattr(msg, "type", None) or getattr(msg, "role", "") or "").lower() - tool_calls = getattr(msg, "tool_calls", None) - if role in ("human", "user"): - trigger = msg - if role in ("ai", "assistant") and not tool_calls: - final_ai = msg + """Last user turn for the member page: trigger + full assistant/tool trail. + + Historically this kept only the final AI text, which dropped tool_calls + from both the member projection and (via fan-in) the room wall. + """ + last_user = -1 + for index, msg in enumerate(messages): + if _message_role(msg) in {"human", "user"}: + last_user = index + if last_user >= 0: + trigger = messages[last_user] + tail = messages[last_user + 1 :] + else: + trigger = None + tail = list(messages) + skip = {"system", "human", "user"} + trail = [msg for msg in tail if _message_role(msg) not in skip] out: list[Any] = [] if trigger is not None: out.append(trigger) - if final_ai is not None: - out.append(final_ai) + out.extend(trail) return out diff --git a/src/octop/infra/agents/thread_artifact.py b/src/octop/infra/agents/thread_artifact.py new file mode 100644 index 0000000000..3fe08c69ed --- /dev/null +++ b/src/octop/infra/agents/thread_artifact.py @@ -0,0 +1,310 @@ +"""Thread artifact path normalize, tool extraction, and API payload shaping.""" + +from __future__ import annotations + +import json +import os +import re +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +from octop.infra.gateway.media.backend_files import ( + extract_workspace_rel, + file_url_to_abs_path, + is_host_absolute_path, +) +from octop.infra.gateway.media.tool_media import tool_name_base +from octop.infra.utils.thread_artifact import ( + ThreadArtifact, + coerce_thread_artifact, +) + +ARTIFACT_TOOL_BASES = frozenset( + { + "write_file", + "edit_file", + "send_file", + "send_file_to_user", + "desktop_screenshot", + "mobile_screenshot", + } +) + +_PATH_KEYS = ( + "path", + "file_path", + "filepath", + "dest", + "target_path", + "output_path", +) + +_PATH_EXT_RE = re.compile(r"\.[A-Za-z][A-Za-z0-9._+-]{0,11}$") +_ABS_OCTOP_RE = re.compile(r"(?:/[\w.-]+)*/\.octop/agents/[^\s\"'<>]+", re.IGNORECASE) +_REL_DIR_RE = re.compile( + r"(?:^|[\s\"'`])((?:outbound|inbound|generated)/[^\s\"'<>]+)", + re.IGNORECASE, +) + + +def _artifact_path_allowed(posix: str) -> bool: + if not posix or posix in {".", "/"}: + return False + if "/_builtin_skills/" in f"/{posix}/" or posix.lstrip("/").startswith("_builtin_skills"): + return False + base = posix.rstrip("/").split("/")[-1] or posix + if re.fullmatch(r"[\d.]+", base): + return False + return bool(_PATH_EXT_RE.search(base)) + + +def artifact_path_allowed(path: str) -> bool: + """True when *path* looks like a user-facing workspace file artifact.""" + raw = _coerce_raw_path(path) + return bool(raw) and _artifact_path_allowed(raw) + + +def _coerce_raw_path(path: str) -> str: + raw = (path or "").strip().replace("\\", "/") + if not raw: + return "" + if raw.startswith("file://"): + return file_url_to_abs_path(raw).replace("\\", "/") + return raw + + +def normalize_artifact_path(path: str, workspace_dir: Path) -> str: + """Return a path for ``threads.artifacts``, or ``\"\"``. + + Absolute paths are stored as-is. Relative paths are joined under + ``workspace_dir`` (the agent work area — not backend ``root_dir``). + """ + raw = _coerce_raw_path(path) + if not raw or not _artifact_path_allowed(raw): + return "" + if is_host_absolute_path(raw): + return raw + rel = extract_workspace_rel(raw) or raw.lstrip("/") + if rel.startswith("workspace/"): + rel = rel.removeprefix("workspace/") + if not rel or not _artifact_path_allowed(rel): + return "" + # Keep the configured workspace spelling stable. On macOS, ``/home`` is a + # firmlink and ``Path.resolve()`` rewrites it to ``/System/Volumes/Data/home``, + # which breaks artifact de-duplication against already-absolute entries. + ws = workspace_dir.expanduser() + return str((ws / rel).as_posix()) + + +def is_artifact_tool_name(name: str | None) -> bool: + base = tool_name_base(name or "").lower() + return base in ARTIFACT_TOOL_BASES + + +def extract_artifact_paths( + *, + tool_name: str = "", + args: str | Mapping[str, Any] | None = None, + result: Any = None, + workspace_dir: Path | None = None, +) -> list[str]: + """Return workspace paths from a write/edit/send/screenshot tool. + + Prefer structured path keys on tool *args*. Scan the tool result text only + when args did not yield a path (screenshots, send_file without a path key). + """ + if not is_artifact_tool_name(tool_name): + return [] + ws = workspace_dir.expanduser() if workspace_dir is not None else None + from_args = _dedupe_paths(_paths_from_args(args), ws) + if from_args: + return from_args + return _dedupe_paths(_paths_from_content(result), ws) + + +def artifacts_for_response( + artifacts: Sequence[object], + workspace_dir: Path, + *, + default_agent_id: str = "", +) -> list[ThreadArtifact]: + """Normalize stored artifacts into structured refs for API / UI. + + Entries that already carry ``agent_id`` keep their path as stored. + Legacy bare paths are joined to *workspace_dir* and stamped with + *default_agent_id* when set. + """ + out: list[ThreadArtifact] = [] + seen: set[str] = set() + default_aid = (default_agent_id or "").strip() + for raw in artifacts: + entry = coerce_thread_artifact(raw) + if entry is None: + continue + if entry.agent_id: + path = entry.path.strip() + aid = entry.agent_id + else: + path = normalize_artifact_path(entry.path, workspace_dir) + aid = default_aid + if not path: + continue + resolved = ThreadArtifact(path=path, agent_id=aid) + key = resolved.dedupe_key() + if key in seen: + continue + seen.add(key) + out.append(resolved) + return out + + +def artifact_paths_for_response(refs: Sequence[ThreadArtifact]) -> list[str]: + """Legacy ``artifacts`` field: unique paths in ref order.""" + out: list[str] = [] + seen: set[str] = set() + for ref in refs: + path = ref.path.strip() + if not path: + continue + key = path.casefold() if os.name == "nt" else path + if key in seen: + continue + seen.add(key) + out.append(path) + return out + + +def artifact_refs_for_response(refs: Sequence[ThreadArtifact]) -> list[dict[str, str]]: + """Structured ``artifact_refs`` field.""" + return [ref.as_ref() for ref in refs] + + +def thread_artifacts_payload( + artifacts: Sequence[object], + workspace_dir: Path, + *, + default_agent_id: str = "", +) -> dict[str, Any]: + """API payload with both legacy path strings and structured refs.""" + refs = artifacts_for_response( + artifacts, + workspace_dir, + default_agent_id=default_agent_id, + ) + return { + "artifacts": artifact_paths_for_response(refs), + "artifact_refs": artifact_refs_for_response(refs), + } + + +def _dedupe_paths(paths: Sequence[str], workspace_dir: Path | None) -> list[str]: + out: list[str] = [] + seen: set[str] = set() + for raw in paths: + if workspace_dir is None: + key = raw.strip().replace("\\", "/") + if not key or key in seen: + continue + seen.add(key) + out.append(key) + continue + resolved = normalize_artifact_path(raw, workspace_dir) + if not resolved: + continue + dedupe_key = resolved.casefold() if os.name == "nt" else resolved + if dedupe_key in seen: + continue + seen.add(dedupe_key) + out.append(resolved) + return out + + +def _pick_path_from_object(parsed: Mapping[str, Any]) -> str: + for key in _PATH_KEYS: + value = parsed.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +def _paths_from_args(raw: str | Mapping[str, Any] | None) -> list[str]: + if raw is None: + return [] + if isinstance(raw, Mapping): + picked = _pick_path_from_object(raw) + return [picked] if picked else [] + text = raw.strip() + if not text: + return [] + try: + parsed = json.loads(text) + except (ValueError, TypeError): + parsed = None + if isinstance(parsed, dict): + picked = _pick_path_from_object(parsed) + return [picked] if picked else _paths_from_text(text) + return _paths_from_text(text) + + +def _paths_from_content(content: Any) -> list[str]: + if content is None: + return [] + if isinstance(content, str): + stripped = content.strip() + if stripped and stripped[0] in "{[": + try: + parsed = json.loads(stripped) + except (ValueError, TypeError): + parsed = None + if isinstance(parsed, dict): + return _paths_from_content(parsed) + if isinstance(parsed, list): + found: list[str] = [] + for item in parsed: + found.extend(_paths_from_content(item)) + return found + return _paths_from_text(stripped) + if isinstance(content, Mapping): + found = [] + picked = _pick_path_from_object(content) + if picked: + found.append(picked) + nested = content.get("source") + if isinstance(nested, Mapping): + url = nested.get("url") + if isinstance(url, str) and url.strip(): + found.append(url.strip()) + return found + if isinstance(content, list): + found = [] + for item in content: + found.extend(_paths_from_content(item)) + return found + return [] + + +def _paths_from_text(text: str) -> list[str]: + if not text: + return [] + abs_match = _ABS_OCTOP_RE.search(text) + if abs_match: + return [abs_match.group(0)] + rel_match = _REL_DIR_RE.search(text) + if rel_match: + return [rel_match.group(1)] + return [] + + +__all__ = [ + "ARTIFACT_TOOL_BASES", + "ThreadArtifact", + "artifact_path_allowed", + "artifact_paths_for_response", + "artifact_refs_for_response", + "artifacts_for_response", + "extract_artifact_paths", + "is_artifact_tool_name", + "normalize_artifact_path", + "thread_artifacts_payload", +] diff --git a/src/octop/infra/db/repos/threads.py b/src/octop/infra/db/repos/threads.py index f8486d1a70..2e34a68be4 100644 --- a/src/octop/infra/db/repos/threads.py +++ b/src/octop/infra/db/repos/threads.py @@ -8,47 +8,24 @@ from octop.infra.db.pool import DatabasePool from octop.infra.db.repos._base import DbRow, bool_int, map_rows, now_ts - -MAX_THREAD_ARTIFACTS = 200 - - -def parse_thread_artifacts(raw: object) -> list[str]: - """Decode the threads.artifacts JSON column into unique non-empty paths.""" - if raw is None: - return [] - if isinstance(raw, (list, tuple)): - parsed: object = list(raw) - elif isinstance(raw, str): - text = raw.strip() - if not text: - return [] - try: - parsed = json.loads(text) - except (ValueError, TypeError): - return [] - else: - return [] - if not isinstance(parsed, list): - return [] - out: list[str] = [] - seen: set[str] = set() - for item in parsed: - if not isinstance(item, str): - continue - path = item.strip() - if not path or path in seen: - continue - seen.add(path) - out.append(path) - return out - - -def merge_thread_artifacts(existing: Sequence[str], incoming: Sequence[str]) -> list[str]: - """Append new paths, keeping insertion order and capping length.""" - merged = parse_thread_artifacts([*existing, *incoming]) - if len(merged) <= MAX_THREAD_ARTIFACTS: - return merged - return merged[-MAX_THREAD_ARTIFACTS:] +from octop.infra.utils.thread_artifact import ( + MAX_THREAD_ARTIFACTS, + ThreadArtifact, + merge_thread_artifacts, + parse_thread_artifacts, + serialize_thread_artifacts, +) + +__all__ = [ + "MAX_THREAD_ARTIFACTS", + "ThreadArtifact", + "ThreadRepo", + "ThreadRow", + "clip_thread_title", + "merge_thread_artifacts", + "parse_thread_artifacts", + "serialize_thread_artifacts", +] @dataclass(frozen=True) @@ -66,7 +43,7 @@ class ThreadRow: model_ref: str | None = None reasoning_mode: str | None = None reasoning_effort: str | None = None - artifacts: tuple[str, ...] = field(default_factory=tuple) + artifacts: tuple[ThreadArtifact, ...] = field(default_factory=tuple) conversation_mode: str | None = None pending_plan_path: str | None = None hitl_policy: str | None = None @@ -313,8 +290,19 @@ def touch_last_active(self, thread_id: str) -> None: (now_ts(), thread_id), ) - def append_artifacts(self, thread_id: str, paths: Sequence[str]) -> None: - incoming = [p.strip() for p in paths if isinstance(p, str) and p.strip()] + def append_artifacts( + self, + thread_id: str, + paths: Sequence[str], + *, + agent_id: str = "", + ) -> None: + aid = (agent_id or "").strip() + incoming = [ + ThreadArtifact(path=p.strip(), agent_id=aid) + for p in paths + if isinstance(p, str) and p.strip() + ] if not incoming: return with self._db.transaction() as conn: @@ -330,7 +318,10 @@ def append_artifacts(self, thread_id: str, paths: Sequence[str]) -> None: return conn.execute( "UPDATE threads SET artifacts = ? WHERE thread_id = ?", - (json.dumps(merged, ensure_ascii=False), thread_id), + ( + json.dumps(serialize_thread_artifacts(merged), ensure_ascii=False), + thread_id, + ), ) def delete(self, thread_id: str) -> None: diff --git a/src/octop/infra/gateway/threads.py b/src/octop/infra/gateway/threads.py index 2ebf4a608c..2b7af05cc8 100644 --- a/src/octop/infra/gateway/threads.py +++ b/src/octop/infra/gateway/threads.py @@ -406,8 +406,14 @@ def update_composer( def touch_last_active(self, thread_id: str) -> None: self._threads.touch_last_active(thread_id) - def append_artifacts(self, thread_id: str, paths: list[str] | tuple[str, ...]) -> None: - self._threads.append_artifacts(thread_id, paths) + def append_artifacts( + self, + thread_id: str, + paths: list[str] | tuple[str, ...], + *, + agent_id: str = "", + ) -> None: + self._threads.append_artifacts(thread_id, paths, agent_id=agent_id) def get_thread(self, thread_id: str) -> ThreadRow | None: return self._threads.get(thread_id) diff --git a/src/octop/infra/utils/thread_artifact.py b/src/octop/infra/utils/thread_artifact.py new file mode 100644 index 0000000000..11fda91e9f --- /dev/null +++ b/src/octop/infra/utils/thread_artifact.py @@ -0,0 +1,119 @@ +"""Thread artifact ref — leaf type for ``threads.artifacts`` JSON. + +No gateway / workspace imports: safe for ``infra.db.repos`` and API layers. +""" + +from __future__ import annotations + +import json +import os +from collections.abc import Mapping, Sequence +from dataclasses import dataclass + +MAX_THREAD_ARTIFACTS = 200 + + +@dataclass(frozen=True) +class ThreadArtifact: + """One workspace file produced during a thread (optionally stamped by agent).""" + + path: str + agent_id: str = "" + + def dedupe_key(self) -> str: + path_key = self.path.casefold() if os.name == "nt" else self.path + return f"{self.agent_id}\0{path_key}" + + def as_ref(self) -> dict[str, str]: + """Structured API item (always includes path; agent_id when set).""" + item: dict[str, str] = {"path": self.path} + if self.agent_id: + item["agent_id"] = self.agent_id + return item + + +def coerce_thread_artifact(item: object) -> ThreadArtifact | None: + if isinstance(item, ThreadArtifact): + path = item.path.strip() + if not path: + return None + return ThreadArtifact(path=path, agent_id=(item.agent_id or "").strip()) + if isinstance(item, str): + path = item.strip() + return ThreadArtifact(path=path) if path else None + if isinstance(item, Mapping): + raw_path = item.get("path") + if not isinstance(raw_path, str) or not raw_path.strip(): + return None + raw_agent = item.get("agent_id") + agent_id = raw_agent.strip() if isinstance(raw_agent, str) else "" + return ThreadArtifact(path=raw_path.strip(), agent_id=agent_id) + return None + + +def parse_thread_artifacts(raw: object) -> list[ThreadArtifact]: + """Decode the threads.artifacts JSON column into unique entries. + + Legacy rows are plain path strings. Newer rows may be + ``{"path": "...", "agent_id": "..."}``. + """ + if raw is None: + return [] + if isinstance(raw, (list, tuple)): + parsed: object = list(raw) + elif isinstance(raw, str): + text = raw.strip() + if not text: + return [] + try: + parsed = json.loads(text) + except (ValueError, TypeError): + return [] + else: + return [] + if not isinstance(parsed, list): + return [] + out: list[ThreadArtifact] = [] + seen: set[str] = set() + for item in parsed: + entry = coerce_thread_artifact(item) + if entry is None: + continue + key = entry.dedupe_key() + if key in seen: + continue + seen.add(key) + out.append(entry) + return out + + +def serialize_thread_artifacts(entries: Sequence[ThreadArtifact]) -> list[str | dict[str, str]]: + """Encode artifacts for the JSON column (strings when agent_id is empty).""" + payload: list[str | dict[str, str]] = [] + for entry in entries: + if entry.agent_id: + payload.append({"path": entry.path, "agent_id": entry.agent_id}) + else: + payload.append(entry.path) + return payload + + +def merge_thread_artifacts( + existing: Sequence[ThreadArtifact | str | Mapping[str, object]], + incoming: Sequence[ThreadArtifact | str | Mapping[str, object]], +) -> list[ThreadArtifact]: + """Append new entries, keeping insertion order and capping length.""" + merged = parse_thread_artifacts([*existing, *incoming]) + if len(merged) <= MAX_THREAD_ARTIFACTS: + return merged + return merged[-MAX_THREAD_ARTIFACTS:] + + +__all__ = [ + "MAX_THREAD_ARTIFACTS", + "ThreadArtifact", + "coerce_thread_artifact", + "merge_thread_artifacts", + "parse_thread_artifacts", + "serialize_thread_artifacts", +] diff --git a/tests/unit/agents/test_thread_artifacts.py b/tests/unit/agents/test_thread_artifacts.py index 7a14842487..da47ee9564 100644 --- a/tests/unit/agents/test_thread_artifacts.py +++ b/tests/unit/agents/test_thread_artifacts.py @@ -11,6 +11,10 @@ from octop.infra.agents.middleware.thread_artifacts import ( ThreadArtifactsMiddleware, + peer_room_thread_id, +) +from octop.infra.agents.thread_artifact import ( + artifact_path_allowed, artifacts_for_response, extract_artifact_paths, is_artifact_tool_name, @@ -21,6 +25,7 @@ from octop.infra.db.repos.agents import AgentRepo from octop.infra.db.repos.threads import ThreadRepo from octop.infra.db.repos.users import UserRepo +from octop.infra.utils.thread_artifact import ThreadArtifact def _ws(tmp_path: Path) -> Path: @@ -29,10 +34,16 @@ def _ws(tmp_path: Path) -> Path: class _FakeThreads: def __init__(self) -> None: - self.calls: list[tuple[str, list[str]]] = [] + self.calls: list[tuple[str, list[str], str]] = [] - def append_artifacts(self, thread_id: str, paths: list[str] | tuple[str, ...]) -> None: - self.calls.append((thread_id, list(paths))) + def append_artifacts( + self, + thread_id: str, + paths: list[str] | tuple[str, ...], + *, + agent_id: str = "", + ) -> None: + self.calls.append((thread_id, list(paths), agent_id)) def _request(name: str, args: dict[str, Any] | None = None) -> MagicMock: @@ -52,6 +63,12 @@ def test_is_artifact_tool_name() -> None: assert not is_artifact_tool_name("ls") +def test_peer_room_thread_id() -> None: + assert peer_room_thread_id("room~member") == "room" + assert peer_room_thread_id("room") == "" + assert peer_room_thread_id("") == "" + + def test_normalize_artifact_path_absolute_passthrough_relative_joins_workspace( tmp_path: Path, ) -> None: @@ -69,17 +86,60 @@ def test_normalize_artifact_path_absolute_passthrough_relative_joins_workspace( assert normalize_artifact_path("_builtin_skills/foo/SKILL.md", ws) == "" +def test_artifact_path_allowed_rejects_builtin_and_extensionless() -> None: + assert artifact_path_allowed("docs/note.md") + assert artifact_path_allowed("outbound/screenshots/a.png") + assert not artifact_path_allowed("_builtin_skills/foo/SKILL.md") + assert not artifact_path_allowed("notes") + assert not artifact_path_allowed("") + + def test_artifacts_for_response_upgrades_legacy_relative_paths(tmp_path: Path) -> None: ws = _ws(tmp_path) abs_path = str((ws / "docs" / "a.md").as_posix()) out = artifacts_for_response( ["docs/a.md", abs_path, "/.octop/agents/main/skills/x.md"], ws, + default_agent_id="host", ) # Relative upgrades to workspace abs; identical abs dedupes; other abs kept. assert out == [ - str((ws / "docs/a.md").as_posix()), - "/.octop/agents/main/skills/x.md", + ThreadArtifact(path=str((ws / "docs/a.md").as_posix()), agent_id="host"), + ThreadArtifact(path="/.octop/agents/main/skills/x.md", agent_id="host"), + ] + + +def test_artifacts_for_response_keeps_producer_path_without_host_join( + tmp_path: Path, +) -> None: + ws = _ws(tmp_path) + out = artifacts_for_response( + [ThreadArtifact(path="outbound/a.md", agent_id="member-a")], + ws, + default_agent_id="host", + ) + assert out == [ThreadArtifact(path="outbound/a.md", agent_id="member-a")] + + +def test_thread_artifacts_payload_has_legacy_and_refs(tmp_path: Path) -> None: + from octop.infra.agents.thread_artifact import thread_artifacts_payload + + ws = _ws(tmp_path) + payload = thread_artifacts_payload( + [ + ThreadArtifact(path="outbound/a.md", agent_id="member-a"), + "docs/b.md", + ], + ws, + default_agent_id="host", + ) + assert payload["artifacts"] == [ + "outbound/a.md", + str((ws / "docs/b.md").as_posix()), + ] + assert payload["artifact_refs"] == [ + {"path": "outbound/a.md", "agent_id": "member-a"}, + {"path": str((ws / "docs/b.md").as_posix()), "agent_id": "host"}, ] @@ -120,7 +180,7 @@ def test_extract_screenshot_from_tool_result_text(tmp_path: Path) -> None: def test_middleware_records_successful_write(tmp_path: Path) -> None: ws = _ws(tmp_path) store = _FakeThreads() - mw = ThreadArtifactsMiddleware(thread_repo=store, workspace_dir=ws) + mw = ThreadArtifactsMiddleware(thread_repo=store, workspace_dir=ws, agent_id="member-a") request = _request("write_file", {"path": "docs/note.md"}) result = ToolMessage(content="ok", tool_call_id="tc1") with patch( @@ -129,7 +189,29 @@ def test_middleware_records_successful_write(tmp_path: Path) -> None: ): out = mw.wrap_tool_call(request, lambda _req: result) assert out is result - assert store.calls == [("thr_1", [str((ws / "docs/note.md").as_posix())])] + assert store.calls == [ + ("thr_1", [str((ws / "docs/note.md").as_posix())], "member-a"), + ] + + +def test_middleware_mirrors_peer_artifacts_to_room(tmp_path: Path) -> None: + ws = _ws(tmp_path) + store = _FakeThreads() + mw = ThreadArtifactsMiddleware(thread_repo=store, workspace_dir=ws, agent_id="member-a") + request = _request("write_file", {"path": "docs/note.md"}) + with patch( + "octop.infra.agents.middleware.thread_artifacts.current_thread_id", + return_value="room~member-a", + ): + mw.wrap_tool_call( + request, + lambda _req: ToolMessage(content="ok", tool_call_id="tc1"), + ) + path = str((ws / "docs/note.md").as_posix()) + assert store.calls == [ + ("room~member-a", [path], "member-a"), + ("room", [path], "member-a"), + ] def test_middleware_records_absolute_write_as_is(tmp_path: Path) -> None: @@ -146,7 +228,7 @@ def test_middleware_records_absolute_write_as_is(tmp_path: Path) -> None: request, lambda _req: ToolMessage(content="ok", tool_call_id="tc1"), ) - assert store.calls == [("thr_1", [abs_path])] + assert store.calls == [("thr_1", [abs_path], "")] def test_middleware_skips_error_and_command(tmp_path: Path) -> None: @@ -195,4 +277,31 @@ def test_append_artifacts_merges_unique(tmp_path: Path) -> None: repo.append_artifacts("thr_1", ["docs/b.md"]) row = repo.get("thr_1") assert row is not None - assert row.artifacts == ("docs/a.md", "docs/b.md") + assert row.artifacts == ( + ThreadArtifact(path="docs/a.md"), + ThreadArtifact(path="docs/b.md"), + ) + + +def test_append_artifacts_dedupes_by_agent_and_path(tmp_path: Path) -> None: + db = SqlitePool(tmp_path / "octop.db") + run_migrations(db) + UserRepo(db).create(username="u", password_hash="h", role="user") + AgentRepo(db).create(agent_id="host", user_id=1, name="Host") + repo = ThreadRepo(db) + repo.insert( + thread_id="room", + agent_id="host", + user_id=1, + channel_type="dashboard", + session_key="sk", + ) + repo.append_artifacts("room", ["outbound/a.md"], agent_id="member-a") + repo.append_artifacts("room", ["outbound/a.md"], agent_id="member-b") + repo.append_artifacts("room", ["outbound/a.md"], agent_id="member-a") + row = repo.get("room") + assert row is not None + assert row.artifacts == ( + ThreadArtifact(path="outbound/a.md", agent_id="member-a"), + ThreadArtifact(path="outbound/a.md", agent_id="member-b"), + ) diff --git a/tests/unit/api/test_chat_polish.py b/tests/unit/api/test_chat_polish.py index 974b797979..870b6b241b 100644 --- a/tests/unit/api/test_chat_polish.py +++ b/tests/unit/api/test_chat_polish.py @@ -10,6 +10,7 @@ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from octop.api.routers.chat.serialize import ( + _enrich_history_tool_media, _entry_matches_thread, _is_offload_placeholder_block, _merge_adjacent_messages, @@ -21,6 +22,51 @@ from octop.infra.utils.llm_text import llm_text_content as _llm_text_content +def test_enrich_history_tool_media_prefers_entry_agent_id(monkeypatch: pytest.MonkeyPatch) -> None: + seen: list[str] = [] + + def fake_enrich(output: str, *, agent_id: str, workspace: Any = None) -> str: + _ = workspace + seen.append(agent_id) + return f"{output}|{agent_id}" + + monkeypatch.setattr( + "octop.infra.gateway.media.tool_media.enrich_tool_output_string_sync", + fake_enrich, + ) + out = _enrich_history_tool_media( + [ + { + "role": "tool", + "agent_id": "child", + "content": [ + { + "type": "tool_result", + "id": "c1", + "name": "desktop_screenshot", + "output": "shot child", + } + ], + }, + { + "role": "tool", + "content": [ + { + "type": "tool_result", + "id": "c2", + "name": "desktop_screenshot", + "output": "shot host", + } + ], + }, + ], + agent_id="host", + ) + assert seen == ["child", "host"] + assert out[0]["content"][0]["output"] == "shot child|child" + assert out[1]["content"][0]["output"] == "shot host|host" + + def test_serialize_history_message_includes_thinking_and_tools() -> None: user = _serialize_history_message(HumanMessage(content="hello")) assert user is not None diff --git a/tests/unit/gateway/test_global_processor_team.py b/tests/unit/gateway/test_global_processor_team.py index 888310a70a..8585d117db 100644 --- a/tests/unit/gateway/test_global_processor_team.py +++ b/tests/unit/gateway/test_global_processor_team.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock @@ -89,8 +90,8 @@ def test_compose_followup_uses_peer_display_name(processor_env: dict) -> None: ) text = processor.compose_followup(msg, result_text="findings", error_text=None) assert "Researcher" in text - assert "findings" not in text - assert "do not repeat" in text.lower() or "不要复述" in text + assert "findings" in text + assert "do not" in text.lower() or "不要" in text def test_compose_followup_team_host_asks_for_wrapup(processor_env: dict) -> None: @@ -106,8 +107,45 @@ def test_compose_followup_team_host_asks_for_wrapup(processor_env: dict) -> None ) text = processor.compose_followup(msg, result_text="findings", error_text=None) assert "Researcher" in text - assert "findings" not in text - assert "do not repeat" in text.lower() or "不要复述" in text + assert "findings" in text + assert "Member answer" in text or "成员正文" in text + assert "do not" in text.lower() or "不要" in text + + +def test_compose_followup_truncates_long_member_result(processor_env: dict) -> None: + from octop.infra.agents.teams.team_manager import _FOLLOWUP_RESULT_MAX_CHARS + + processor = processor_env["processor"] + processor._agent_repo.create(agent_id="host", user_id=1, name="Host", kind="team") + msg = InboxMessage( + id="job-3", + target_agent_id="child", + source_agent_id="host", + source_thread_id="thr_parent", + message="survey market", + user_id=1, + ) + huge = "建议采纳此项。" + ("x" * (_FOLLOWUP_RESULT_MAX_CHARS + 200)) + text = processor.compose_followup(msg, result_text=huge, error_text=None) + assert "建议采纳此项。" in text + assert huge not in text + assert "…" in text + assert len(text) < len(huge) + 800 + + +def test_compose_followup_empty_result_placeholder(processor_env: dict) -> None: + processor = processor_env["processor"] + processor._agent_repo.create(agent_id="host", user_id=1, name="Host", kind="team") + msg = InboxMessage( + id="job-4", + target_agent_id="child", + source_agent_id="host", + source_thread_id="thr_parent", + message="survey market", + user_id=1, + ) + text = processor.compose_followup(msg, result_text=" ", error_text=None) + assert "(empty)" in text @pytest.mark.asyncio @@ -1042,8 +1080,247 @@ async def test_fan_in_persists_member_turn_without_dispatch_prompt( repo.append_if_ready.assert_called_once() thread_id, inputs = repo.append_if_ready.call_args.args assert thread_id == "thr_parent" - assert len(inputs) == 1 - assert inputs[0].role in {"ai", "assistant"} + roles = [item.role for item in inputs] + assert roles[0] in {"ai", "assistant"} + assert any( + item.role in {"ai", "assistant"} and "please rest" in item.message_json for item in inputs + ) + assert any("read_file" in item.message_json for item in inputs) + assert all("internal dispatch" not in item.message_json for item in inputs) + + +@pytest.mark.asyncio +async def test_fan_in_stamps_edited_files_on_room_history( + processor_env: dict, +) -> None: + from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + from octop_harness.teams.util import PeerCall + + processor = processor_env["processor"] + processor._agent_repo.create(agent_id="host", user_id=1, name="Host", kind="team") + repo = MagicMock() + repo.append_if_ready = MagicMock(return_value=1) + processor.replace_thread_message_repo(repo) + + await processor.teams.fan_in_peer_turn( + PeerCall( + from_agent_id="host", + to_agent_id="child", + user_id=1, + message="ask", + source_thread_id="thr_parent", + source_session_key=None, + ), + [ + HumanMessage(content="internal dispatch"), + AIMessage( + content="", + tool_calls=[ + { + "name": "write_file", + "id": "c1", + "args": {"path": "notes/plan.md"}, + }, + { + "name": "edit_file", + "id": "c2", + "args": {"path": "notes/plan.md"}, + }, + { + "name": "read_file", + "id": "c3", + "args": {"path": "SOUL.md"}, + }, + ], + ), + ToolMessage(content="wrote plan", tool_call_id="c1", name="write_file"), + ToolMessage(content="edited plan", tool_call_id="c2", name="edit_file"), + ToolMessage(content="soul", tool_call_id="c3", name="read_file"), + AIMessage(content="wrote the plan"), + ], + live_streamed=True, + ) + repo.append_if_ready.assert_called_once() + _thread_id, inputs = repo.append_if_ready.call_args.args + roles = [item.role for item in inputs] + assert "tool" in roles + assert roles.count("tool") == 3 + final = next( + item + for item in inputs + if item.role in {"ai", "assistant"} and "wrote the plan" in item.message_json + ) + wire = json.loads(final.message_json) + data = wire.get("data") if isinstance(wire, dict) else wire + kwargs = (data or {}).get("additional_kwargs") or {} + assert kwargs.get("speaker_agent_id") == "child" + assert kwargs.get("edited_files") == ["notes/plan.md"] + tool_wire = json.loads(next(item for item in inputs if item.role == "tool").message_json) + tool_data = tool_wire.get("data") if isinstance(tool_wire, dict) else tool_wire + assert (tool_data or {}).get("additional_kwargs", {}).get("speaker_agent_id") == "child" + + +@pytest.mark.asyncio +async def test_record_peer_turn_keeps_tool_trail_on_member_page( + processor_env: dict, +) -> None: + from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + from octop_harness.teams.util import PeerCall + + processor = processor_env["processor"] + processor._agent_repo.create(agent_id="host", user_id=1, name="Host", kind="team") + repo = MagicMock() + repo.append_if_ready = MagicMock(return_value=1) + repo.projection_status = MagicMock(return_value="pending") + repo.mark_projection = MagicMock() + repo.page = MagicMock(return_value=([], False)) + processor.replace_thread_message_repo(repo) + processor._agent_manager.get_agent.return_value = SimpleNamespace( + aget_history=AsyncMock(return_value=[HumanMessage(content="用户原话")]) + ) + + await processor.record_peer_turn( + PeerCall( + from_agent_id="host", + to_agent_id="child", + user_id=1, + message="请写计划", + source_thread_id="thr_parent", + source_session_key=None, + job_id="job1", + ), + "thr_parent~child", + { + "messages": [ + HumanMessage(content="请写计划"), + AIMessage( + content="", + tool_calls=[ + { + "name": "write_file", + "id": "c1", + "args": {"path": "plan.md"}, + } + ], + ), + ToolMessage(content="ok", tool_call_id="c1", name="write_file"), + AIMessage(content="写好了"), + ] + }, + ) + + member_calls = [ + call for call in repo.append_if_ready.call_args_list if call.args[0] == "thr_parent~child" + ] + assert member_calls + inputs = member_calls[0].args[1] + roles = [item.role for item in inputs] + assert "tool" in roles + assert any( + item.role in {"ai", "assistant"} and "write_file" in item.message_json for item in inputs + ) + assert any( + item.role in {"ai", "assistant"} and "写好了" in item.message_json for item in inputs + ) + room_calls = [ + call for call in repo.append_if_ready.call_args_list if call.args[0] == "thr_parent" + ] + assert room_calls + room_roles = [item.role for item in room_calls[0].args[1]] + assert "tool" in room_roles + + +@pytest.mark.asyncio +async def test_fan_in_truncates_large_tool_results( + processor_env: dict, +) -> None: + from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + from octop_harness.teams.util import PeerCall + + from octop.infra.agents.teams.team_manager import _HISTORY_TOOL_RESULT_MAX_CHARS + + processor = processor_env["processor"] + processor._agent_repo.create(agent_id="host", user_id=1, name="Host", kind="team") + repo = MagicMock() + repo.append_if_ready = MagicMock(return_value=1) + processor.replace_thread_message_repo(repo) + huge = "x" * (_HISTORY_TOOL_RESULT_MAX_CHARS + 500) + + await processor.teams.fan_in_peer_turn( + PeerCall( + from_agent_id="host", + to_agent_id="child", + user_id=1, + message="ask", + source_thread_id="thr_parent", + source_session_key=None, + ), + [ + HumanMessage(content="dispatch"), + AIMessage( + content="", + tool_calls=[{"name": "read_file", "id": "c1", "args": {"path": "big.md"}}], + ), + ToolMessage(content=huge, tool_call_id="c1", name="read_file"), + AIMessage(content="done"), + ], + live_streamed=True, + ) + _thread_id, inputs = repo.append_if_ready.call_args.args + tool = next(item for item in inputs if item.role == "tool") + wire = json.loads(tool.message_json) + data = wire.get("data") if isinstance(wire, dict) else wire + content = (data or {}).get("content") or "" + assert len(content) <= _HISTORY_TOOL_RESULT_MAX_CHARS + assert content.endswith("…") + assert huge not in content + + +@pytest.mark.asyncio +async def test_fan_in_stamps_edited_files_when_final_text_missing( + processor_env: dict, +) -> None: + from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + from octop_harness.teams.util import PeerCall + + processor = processor_env["processor"] + processor._agent_repo.create(agent_id="host", user_id=1, name="Host", kind="team") + repo = MagicMock() + repo.append_if_ready = MagicMock(return_value=1) + processor.replace_thread_message_repo(repo) + + await processor.teams.fan_in_peer_turn( + PeerCall( + from_agent_id="host", + to_agent_id="child", + user_id=1, + message="ask", + source_thread_id="thr_parent", + source_session_key=None, + ), + [ + HumanMessage(content="dispatch"), + AIMessage( + content="", + tool_calls=[ + { + "name": "write_file", + "id": "c1", + "args": {"path": "notes/only.md"}, + } + ], + ), + ToolMessage(content="ok", tool_call_id="c1", name="write_file"), + ], + live_streamed=True, + ) + _thread_id, inputs = repo.append_if_ready.call_args.args + assert inputs + ai = next(item for item in inputs if item.role in {"ai", "assistant"}) + wire = json.loads(ai.message_json) + data = wire.get("data") if isinstance(wire, dict) else wire + kwargs = (data or {}).get("additional_kwargs") or {} + assert kwargs.get("edited_files") == ["notes/only.md"] @pytest.mark.asyncio From 7617133b31c8ddf9f13dba8b167728c26b894e99 Mon Sep 17 00:00:00 2001 From: Duhu Date: Fri, 25 Sep 2026 21:51:33 +0800 Subject: [PATCH 017/126] =?UTF-8?q?feat(connectors):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E4=BC=81=E6=9F=A5=E6=9F=A5=E4=B8=80=E9=94=AE=20OAuth=EF=BC=8C?= =?UTF-8?q?=E4=BF=9D=E7=95=99=20internal=20HTTP=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=20(#1106)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * QMCP-2014 feat(connectors): restore QCC OAuth with internal HTTP transport * QMCP-2014 docs(connectors): record QCC OAuth end-to-end acceptance --------- Co-authored-by: DuHu --- CHANGELOG.md | 4 + docs/connectors-qcc.md | 47 +- src/octop/api/routers/connectors.py | 8 +- src/octop/i18n/en.json | 2 +- src/octop/i18n/zh.json | 2 +- src/octop/infra/connectors/builder.py | 10 + src/octop/infra/connectors/catalog.py | 17 +- src/octop/infra/connectors/qcc.py | 38 +- src/octop/infra/connectors/service.py | 73 ++- tests/integration/test_connectors_api.py | 31 +- tests/unit/connectors/test_qcc.py | 17 +- tests/unit/connectors/test_qcc_grant.py | 30 ++ tests/unit/connectors/test_qcc_oauth_grant.py | 429 ++++++++++++++++++ .../connectors/test_qcc_oauth_registry.py | 85 ++++ 14 files changed, 741 insertions(+), 52 deletions(-) create mode 100644 tests/unit/connectors/test_qcc_oauth_grant.py create mode 100644 tests/unit/connectors/test_qcc_oauth_registry.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c2e08c202d..87f6012916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ## [Unreleased] +### 新增 + +- 企查查内置连接器恢复一键 OAuth:保留 internal HTTP 工具加载及已有 API Key,支持五类资源共享刷新与远程解绑;注明系统浏览器及公网 HTTPS 回调要求。 + ### 变更 - 运行时依赖改为 `octop-harness[all]` / `octop-gateway` / `octop-memory` / `octop-browser` 1.0.0(原 `orcakit-harness-agent` / `harness-*`);文档、UI 文案与生成路径同步改为 `octop-*`(`~/.harness-browser` 仅作迁移/拒绝源) diff --git a/docs/connectors-qcc.md b/docs/connectors-qcc.md index c09fb28557..1436621cb8 100644 --- a/docs/connectors-qcc.md +++ b/docs/connectors-qcc.md @@ -1,6 +1,6 @@ -# 企查查 MCP 连接器 +# 企查查 MCP OAuth 连接器 -一张企查查卡片、一份 API Key,连接以下五个企业数据 Server。查询范围与额度以企查查账户权限为准。 +一张企查查卡片、一次 OAuth 授权,连接以下五个 Server。查询范围与额度以账户权限为准。 | 数据类别 | MCP 地址 | 工具名称前缀 | | --- | --- | --- | @@ -10,32 +10,37 @@ | 经营信息 | `https://agent.qcc.com/mcp/operation/stream` | `operation__` | | 人员信息 | `https://agent.qcc.com/mcp/executive/stream` | `executive__` | -## 使用 +## 使用与回调部署 -1. 打开「连接器 → 内置连接器」,选择「企查查」。 -2. 登录 [企查查智能体平台](https://agent.qcc.com/),在个人中心复制 API Key,粘贴到 Octop。无需 OAuth 回调。 -3. 确认已开通的类别探测成功(未开通的类别会跳过),保存连接器,并在对话中选择它。 -4. 输入企业全称及查询需求,例如「查询思必驰科技股份有限公司的工商变更」。 +1. **请用系统浏览器授权**:在 Chrome 等系统浏览器打开 Octop,进入「连接器 → 内置连接器 → 企查查」,点击「一键授权」。内嵌/桌面弹窗可能无法回调;显示企查查授权成功不等于 Octop 已收到回调。 +2. 本机部署可以使用 `http://127.0.0.1:端口` 或 `http://localhost:端口`。服务和浏览器必须在同一台电脑。 +3. 公网部署必须配置正确的 **HTTPS 公网地址及回调** `/api/connectors/oauth/callback`,并按企查查要求完成回调域名/地址登记。公网 HTTP 会在发起授权前被拒绝;不会降级为 API Key,也不能用 localhost 替代远端服务器地址。 +4. 完成登录授权后,确认 Octop 显示已授权、探测成功,在对话中选择卡片并使用默认执行模式(Craft),输入企业全称及查询需求。Ask / Plan 模式按产品设计禁用 MCP,不能用于验证连接器调用。 -五个 MCP 地址共用同一份 Bearer API Key。连接前校验目标的 Protected Resource Metadata, -确认 resource 与 issuer 匹配;连接器不会向列表以外的 MCP 地址发送凭证。 +使用 DCR + 授权码 + PKCE S256,Client Name 为 **`Octop Connector`**,scope 为 `mcp:tools`,授权服务器为 `https://agent.qcc.com`。五类资源共用卡片保存的同一份授权;只向固定资源发送凭证,并校验 Protected Resource Metadata。 ## 共享授权与生命周期 -- 一条连接器记录保存一份加密 API Key。每张卡片独立管理自己的账户凭证。 -- Octop 以 `mcp_mode=internal` 托管内部 HTTP MCP:对话按 HTTP 加载 `/api/internal/mcp/qcc/…`, - 再聚合五类工具并加上类别前缀。不是进程内 gateway 适配器。 - 一张卡片即可选用所有五类工具,无需创建五个独立连接器。 -- 删除卡片只清除本地凭证,不会远程吊销 API Key。 -- 探测会报告每类服务的结果;任一类别成功即整体可用。运行时工具发现同样只聚合成功类别。 +- 保留 `mcp_mode=internal`:对话通过内部 HTTP MCP 加载五类工具,不使用进程内 gateway 适配器。继续使用上游的精选工具策略;五类数据接入不意味着全部工具均暴露给模型。 +- 探测和工具发现允许部分类别失败,聚合账户可用的类别,不自动申请额外权限。 +- 加密持久化访问令牌、刷新令牌及动态客户端信息。即将过期时刷新;401 最多刷新后重试一次。刷新保留内部 HTTP 访问令牌,重启后读取持久化凭证。 +- 同一卡片的并发请求共享刷新锁,解绑也使用该锁,防止刷新覆盖解绑。**锁仅覆盖同一进程、同一应用仓库对象,不支持多 worker/多副本同时使用同一 rotating refresh token**;部署一个应用进程,或另行实现跨进程协调。 +- 删除 OAuth 卡片先远程撤销最新 refresh token,再删除本地记录。撤销失败保留凭证,以便重试。 +- 已有 API Key 卡片继续运行,401 不执行 OAuth 刷新,删除仅清除本地凭证,不远程吊销 API Key。可通过一键授权迁移;新卡片以 OAuth 为默认流程。 ## 验证范围 -2026-09-23,贡献者在 Octop 中使用合成凭证和 HTTP MockTransport,通过真实 MCP SDK -验证五类服务的初始化、分页工具发现及工具调用;另外覆盖部分类别失败、内部接口鉴权 -及跨用户删除权限。测试不访问真实账户。 +自动化测试使用合成凭证、HTTP MockTransport 和真实 MCP SDK,覆盖五类资源初始化、分页及调用、并发刷新、401 重试、数据库重开、解绑与刷新竞争、旧 API Key 兼容,以及 internal HTTP 配置进入对话的路径。公网 HTTP 回调拒绝与其它 OAuth 连接器行为有回归覆盖。 -五类服务的真实业务调用仍需授权账户验收。工具数量由服务端决定,不作为固定契约。 +2026-09-24,在本分支实现提交 `bf1ea4e` 上完成新的本地真实账户验收(独立实例 `127.0.0.1:8089`): -官方入口:。 -连接器图标来自该站点公开的 `/favicon-qcc.png`,用于标识企查查服务。 +- 系统 Chrome 完成 OAuth;五类 Server 共发现 6 个精选工具。 +- 五类资源各实际调用一项,均返回 HTTP 200、无 JSON-RPC error、`isError=false`。 +- 将本地到期时间设为过期,触发真实远端刷新;5 个并发发现请求均成功,访问/刷新令牌轮换,内部访问令牌不变。未等待自然到期,精确刷新次数由单元测试覆盖。 +- 完整重启后,无需重新授权,五类资源再次调用成功。 +- 通过 Octop Dashboard WebSocket 发起真实模型对话,在 Craft 模式选择该连接器,实际调用 `company__get_company_profile` 并收到成功工具结果。初次 Ask 模式不暴露 MCP,是模式规则而非 OAuth 故障。 +- 解绑返回 204、本地记录删除、旧内部入口返回 404;使用刚撤销的刷新令牌再次刷新,远端返回 400 / `invalid_grant`。验收授权已撤销。 + +这是五类资源各一项的抽样验收;没有逐一验证全部上游工具。公网 HTTPS 回调、桌面内嵌弹窗及多进程部署未做真实端到端验收。公网部署仍需按上述回调要求配置并单独验证。 + +官方入口:。图标来自该站点公开的 `/favicon-qcc.png`。 diff --git a/src/octop/api/routers/connectors.py b/src/octop/api/routers/connectors.py index 34b1b8cf3d..ce9f6cf093 100644 --- a/src/octop/api/routers/connectors.py +++ b/src/octop/api/routers/connectors.py @@ -899,7 +899,13 @@ async def delete_instance( cli_creds = {"instance_id": instance_id} else: cli_creds = {**cli_creds, "instance_id": instance_id} - repo.delete(instance_id) + if inst.kind == "qcc": + try: + await _connector_service(server).disconnect_qcc(instance_id) + except ValueError as exc: + raise OctopError(ErrorCode.CONNECTOR_INVALID_CREDENTIALS, str(exc)) from exc + else: + repo.delete(instance_id) if cli_creds is not None: cleanup_creds_cli_dirs(inst.kind, cli_creds) _schedule_connector_reload(server, user_id, all_users=inst.shared) diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index d88626d5c5..a3063e884b 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -377,7 +377,7 @@ "CONNECTOR_NOT_FOUND": "Connector not found.", "CONNECTOR_NAME_TAKEN": "This connector name is already in use. Please choose another.", "CONNECTOR_INVALID_CREDENTIALS": "Invalid connector credentials.", - "CONNECTOR_OAUTH_HTTPS_REQUIRED": "Octop is currently being accessed over public HTTP, but Notion OAuth callbacks require HTTPS. Use HTTPS, or access Octop locally through localhost or 127.0.0.1.", + "CONNECTOR_OAUTH_HTTPS_REQUIRED": "Octop is currently being accessed over public HTTP, but MCP OAuth callbacks require HTTPS. Use HTTPS, or access Octop locally through localhost or 127.0.0.1.", "CONNECTOR_MCP_URL_INVALID": "Invalid MCP server URL: {reason}", "CONNECTOR_MCP_HTTPS_REQUIRED": "Public MCP server URLs must use HTTPS. Local and LAN addresses may use HTTP.", "CONNECTOR_KIND_UNSUPPORTED": "Unsupported connector type.", diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index 01df6dcd15..8de66ad7da 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -377,7 +377,7 @@ "CONNECTOR_NOT_FOUND": "未找到连接器。", "CONNECTOR_NAME_TAKEN": "该连接器名称已被使用,请换一个名称。", "CONNECTOR_INVALID_CREDENTIALS": "连接器凭证无效。", - "CONNECTOR_OAUTH_HTTPS_REQUIRED": "当前通过公网 HTTP 访问 Octop,Notion 授权回调地址需要 HTTPS。请改用 HTTPS,或通过 localhost、127.0.0.1 在本机访问 Octop。", + "CONNECTOR_OAUTH_HTTPS_REQUIRED": "当前通过公网 HTTP 访问 Octop,MCP OAuth 授权回调地址需要 HTTPS。请改用 HTTPS,或通过 localhost、127.0.0.1 在本机访问 Octop。", "CONNECTOR_MCP_URL_INVALID": "MCP 服务地址无效:{reason}", "CONNECTOR_MCP_HTTPS_REQUIRED": "公网 MCP 服务地址必须使用 HTTPS。本机与局域网地址可以使用 HTTP。", "CONNECTOR_KIND_UNSUPPORTED": "不支持的连接器类型。", diff --git a/src/octop/infra/connectors/builder.py b/src/octop/infra/connectors/builder.py index 600f5c7215..883cebcca6 100644 --- a/src/octop/infra/connectors/builder.py +++ b/src/octop/infra/connectors/builder.py @@ -230,6 +230,16 @@ def validate_create_credentials( raise ValueError("token is required") return {"token": token} + # Preserve credentials submitted by older QCC clients during migration. + if kind == "qcc" and credentials.get("api_key") and not credentials.get("access_token"): + api_key = str(credentials["api_key"]).strip() + if not api_key: + raise ValueError("api_key is required") + return { + "api_key": api_key, + "internal_token": new_internal_token(), + } + if entry.auth_kind == "oauth2": access_token = str( credentials.get("access_token") or credentials.get("token") or "" diff --git a/src/octop/infra/connectors/catalog.py b/src/octop/infra/connectors/catalog.py index 11526769b7..026675f8fb 100644 --- a/src/octop/infra/connectors/catalog.py +++ b/src/octop/infra/connectors/catalog.py @@ -65,7 +65,7 @@ class ConnectorCatalogEntry: # None means no restriction (all tools from the MCP server are available). allowed_tools: tuple[str, ...] | None = None # Catalog-driven remote MCP OAuth (Notion / Ardot / Linear…): - # when auth_kind=oauth2 + mcp_mode=remote and both issuer + mcp_url are set, + # when auth_kind=oauth2 + mcp_mode=remote/internal and issuer + mcp_url are set, # Octop uses DCR + PKCE against oauth_issuer and talks to mcp_url. oauth_issuer: str | None = None mcp_url: str | None = None @@ -87,10 +87,10 @@ def uses_internal_http_mcp(entry: ConnectorCatalogEntry) -> bool: def is_mcp_oauth_remote(entry: ConnectorCatalogEntry) -> bool: - """True when this catalog entry is a dynamic-OAuth remote MCP connector.""" + """True when this catalog entry is a dynamic-OAuth MCP connector (remote or internal HTTP).""" return ( entry.auth_kind == "oauth2" - and entry.mcp_mode == "remote" + and entry.mcp_mode in {"remote", "internal"} and bool(entry.oauth_issuer) and bool(entry.mcp_url) ) @@ -377,8 +377,8 @@ def get_mcp_oauth_remote(kind: str) -> ConnectorCatalogEntry | None: ConnectorCatalogEntry( kind="qcc", name="企查查", - description="用一份 API Key 接入企查查五类 MCP:企业数据、风险数据、知识产权、经营信息与董监高信息", - auth_kind="api_key", + description="请用系统浏览器授权。一键 OAuth 接入企业、风险、知识产权、经营及董监高五类数据", + auth_kind="oauth2", doc_url="https://agent.qcc.com/", icon="qcc", color="#008CFF", @@ -388,7 +388,12 @@ def get_mcp_oauth_remote(kind: str) -> ConnectorCatalogEntry | None: quick_auth_url="https://agent.qcc.com/", guide_url="https://agent.qcc.com/guide", manual_url="https://agent.qcc.com/", - auth_hint="登录企查查智能体平台,在个人中心复制 API Key 并粘贴到下方;查询范围以账户权限为准,未开通的类别会跳过", + auth_hint="请用系统浏览器授权;内嵌/桌面弹窗可能无法回调。本机可用 localhost/127.0.0.1,公网部署须配置 HTTPS 回调;查询范围以账户权限为准。", + oauth_issuer="https://agent.qcc.com", + mcp_url="https://agent.qcc.com/mcp/company/stream", + oauth_resource="https://agent.qcc.com/mcp/company/stream", + oauth_scopes="mcp:tools", + remote_transport="streamable_http", ), ConnectorCatalogEntry( kind="tencent-ardot", diff --git a/src/octop/infra/connectors/qcc.py b/src/octop/infra/connectors/qcc.py index 282a10aa2f..ceda3f53b5 100644 --- a/src/octop/infra/connectors/qcc.py +++ b/src/octop/infra/connectors/qcc.py @@ -1,4 +1,4 @@ -"""One QCC API key, five fixed MCP resources, one namespaced tool surface.""" +"""One QCC OAuth grant (or legacy API key), five fixed MCP resources, one namespaced tool surface.""" from __future__ import annotations @@ -9,6 +9,7 @@ from mcp import ClientSession from mcp.client.streamable_http import streamable_http_client +from octop.infra.connectors.oauth.mcp import _ensure_mcp_oauth_url, fetch_authorization_metadata from octop.infra.utils.ssrf_guard import safe_request ISSUER = "https://agent.qcc.com" @@ -35,7 +36,13 @@ _metadata_ok_until: dict[str, float] = {} +def is_oauth_grant(creds: dict[str, Any]) -> bool: + return bool(creds.get("oauth_client_id") or creds.get("refresh_token")) + + def bearer_token(creds: dict[str, Any]) -> str: + if is_oauth_grant(creds): + return str(creds.get("access_token") or "").strip() return str( creds.get("api_key") or creds.get("token") or creds.get("access_token") or "" ).strip() @@ -143,3 +150,32 @@ async def probe(token: str) -> dict[str, Any]: "servers": servers, **({"error": f"QCC MCP probe failed: {', '.join(failed)}"} if failed else {}), } + + +def unauthorized(exc: BaseException) -> bool: + if isinstance(exc, BaseExceptionGroup): + return any(unauthorized(child) for child in exc.exceptions) + return isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 401 + + +async def revoke(creds: dict[str, Any]) -> None: + token = str(creds.get("refresh_token") or "") + if not token: + return + metadata = await fetch_authorization_metadata(ISSUER) + endpoint = await _ensure_mcp_oauth_url( + str(metadata.get("revocation_endpoint") or ""), + issuer=ISSUER, + field="revocation_endpoint", + ) + response = await safe_request( + "POST", + endpoint, + data={ + "client_id": str(creds.get("oauth_client_id") or ""), + "token": token, + "token_type_hint": "refresh_token", + }, + timeout=20.0, + ) + response.raise_for_status() diff --git a/src/octop/infra/connectors/service.py b/src/octop/infra/connectors/service.py index 89744047a3..872db598fe 100644 --- a/src/octop/infra/connectors/service.py +++ b/src/octop/infra/connectors/service.py @@ -7,6 +7,7 @@ import time from pathlib import Path from typing import Any +from weakref import WeakKeyDictionary from octop.config import OctopConfig from octop.infra.connectors import qcc @@ -50,6 +51,9 @@ _OAUTH_REFRESH_SKEW_SEC = 120 +# Shared only within one process and application repository. +_QCC_LOCKS: WeakKeyDictionary[ConnectorRepo, dict[str, asyncio.Lock]] = WeakKeyDictionary() + class ConnectorNameTakenError(ValueError): """Raised when a connector display name is already owned by the user.""" @@ -138,10 +142,7 @@ async def ensure_fresh_credentials( kind: str, ) -> dict[str, Any]: if kind == "qcc": - row = self._repo.get(instance_id) - if row is None or row.kind != "qcc" or row.status != "active": - return {} - return self.decrypt(instance_id) + return await self._fresh_qcc(instance_id) creds = self.decrypt(instance_id) entry = get_catalog_entry(kind) if entry is None or entry.auth_kind != "oauth2": @@ -164,10 +165,52 @@ async def ensure_fresh_credentials( self.encrypt_and_store(instance_id=instance_id, payload=creds) return creds + def _qcc_lock(self, instance_id: str) -> asyncio.Lock: + locks = _QCC_LOCKS.setdefault(self._repo, {}) + return locks.setdefault(instance_id, asyncio.Lock()) + + async def _fresh_qcc( + self, instance_id: str, *, rejected_token: str | None = None + ) -> dict[str, Any]: + async with self._qcc_lock(instance_id): + row = self._repo.get(instance_id) + if row is None or row.kind != "qcc" or row.status != "active": + return {} + creds = self.decrypt(instance_id) + if not qcc.is_oauth_grant(creds): + return creds + token = qcc.bearer_token(creds) + # Another request may already have rotated the token rejected by a 401. + force = rejected_token is not None and rejected_token == token + expires = int(creds.get("expires_at") or 0) + if not force and expires > int(time.time()) + _OAUTH_REFRESH_SKEW_SEC: + return creds + if not creds.get("refresh_token"): + if force or (expires and expires <= int(time.time())): + raise ValueError("QCC authorization expired; please authorize again") + return creds + if not creds.get("oauth_client_id"): + raise ValueError("QCC client registration missing; please authorize again") + refreshed = await refresh_oauth_credentials( + kind="qcc", creds=creds, settings_repo=self._settings_repo + ) + creds.update(refreshed) + self.encrypt_and_store(instance_id=instance_id, payload=creds) + return creds + async def _qcc_request( self, instance_id: str, resource: str, method: str, params: dict[str, Any] ) -> dict[str, Any]: - creds = await self.ensure_fresh_credentials(instance_id, "qcc") + creds = await self._fresh_qcc(instance_id) + token = qcc.bearer_token(creds) + if not token: + raise ValueError("QCC connector is disconnected") + try: + return await qcc.request_resource(resource, token, method, params) + except Exception as exc: + if not qcc.is_oauth_grant(creds) or not qcc.unauthorized(exc): + raise + creds = await self._fresh_qcc(instance_id, rejected_token=token) token = qcc.bearer_token(creds) if not token: raise ValueError("QCC connector is disconnected") @@ -194,7 +237,7 @@ async def handle_qcc_request(self, instance_id: str, body: dict[str, Any]) -> di continue tools.extend(qcc.namespace_tools(resource, item)) if not tools: - raise ValueError("QCC MCP request failed; check API Key or try again") + raise ValueError("QCC MCP request failed; check connection or authorize again") result: dict[str, Any] = {"tools": tools} else: params = dict(body.get("params") or {}) @@ -212,10 +255,26 @@ async def handle_qcc_request(self, instance_id: str, body: dict[str, Any]) -> di "id": body.get("id"), "error": { "code": -32603, - "message": "QCC MCP request failed; check API Key or try again", + "message": "QCC MCP request failed; check connection or authorize again", }, } + async def disconnect_qcc(self, instance_id: str) -> None: + async with self._qcc_lock(instance_id): + row = self._repo.get(instance_id) + if row is None: + return + if row.kind != "qcc": + raise ValueError("Not a QCC connector") + try: + creds = self.decrypt(instance_id) + if qcc.is_oauth_grant(creds): + await qcc.revoke(creds) + except Exception as exc: + # Keep the encrypted grant so the user can retry remote revocation. + raise ValueError("QCC revocation failed; retry disconnect") from exc + self._repo.delete(instance_id) + def reserved_builtin_mcp_names(self, user_id: int) -> set[str]: names: set[str] = set() for inst in self._repo.list_by_user(user_id): diff --git a/tests/integration/test_connectors_api.py b/tests/integration/test_connectors_api.py index cea36f2bfa..6f10b58d3c 100644 --- a/tests/integration/test_connectors_api.py +++ b/tests/integration/test_connectors_api.py @@ -66,10 +66,10 @@ async def test_catalog(env): assert weiyun["category"] == "office" assert weiyun.get("quick_auth_url") == "https://www.weiyun.com/act/openclaw" qcc = next(e for e in r.json() if e["kind"] == "qcc") - assert qcc["auth_kind"] == "api_key" + assert qcc["auth_kind"] == "oauth2" assert qcc["mcp_mode"] == "internal" - assert qcc["oauth_mode"] is None - assert qcc["oauth_ready"] is False + assert qcc["oauth_mode"] == "dynamic" + assert qcc["oauth_ready"] is True assert qcc["category"] == "professional" openalex = next(e for e in r.json() if e["kind"] == "openalex") assert openalex == { @@ -404,7 +404,8 @@ async def test_auth_info(env): assert data["auth_hint"] -async def test_oauth_start_public_http_notion_error_is_actionable(tmp_octop_home: Path): +@pytest.mark.parametrize("kind", ["notion", "qcc"]) +async def test_oauth_start_public_http_notion_error_is_actionable(tmp_octop_home: Path, kind: str): write_octop_config(tmp_octop_home) async with octop_client(tmp_octop_home) as (c, _srv): await bootstrap_admin(c, tmp_octop_home) @@ -412,14 +413,14 @@ async def test_oauth_start_public_http_notion_error_is_actionable(tmp_octop_home mocked_start = AsyncMock() with patch("octop.api.routers.connectors.start_oauth_for_target", mocked_start): r = await c.post( - "/api/connectors/oauth/notion/start", + f"/api/connectors/oauth/{kind}/start", headers={**auth, "host": "58.87.70.170"}, json={"redirect_after": "/connectors"}, ) assert r.status_code == 400 body = r.json() assert body["error"]["code"] == "CONNECTOR_OAUTH_HTTPS_REQUIRED" - assert "Notion" in body["error"]["message"] + assert "OAuth" in body["error"]["message"] assert "HTTPS" in body["error"]["message"] mocked_start.assert_not_awaited() @@ -701,18 +702,31 @@ async def test_custom_mcp_oauth_start_unified(env): assert call_kwargs["mcp_url"] == "https://mcp.example.com/mcp" -async def test_qcc_gateway_auth_five_resources_and_disconnect(env, monkeypatch): +@pytest.mark.parametrize("oauth", [False, True]) +async def test_qcc_gateway_auth_five_resources_and_disconnect(env, monkeypatch, oauth): from octop.api.routers.internal_mcp import _service from octop.infra.connectors import qcc c, srv, auth, _ = env + revoke = AsyncMock() + monkeypatch.setattr(qcc, "revoke", revoke) + credentials = ( + { + "access_token": "synthetic", + "refresh_token": "refresh", + "oauth_client_id": "client", + "expires_at": 9999999999, + } + if oauth + else {"api_key": "synthetic"} + ) created = await c.post( "/api/connector-instances", headers=auth, json={ "kind": "qcc", "display_name": "QCC", - "credentials": {"api_key": "synthetic"}, + "credentials": credentials, }, ) assert created.status_code == 201 @@ -737,6 +751,7 @@ async def test_qcc_gateway_auth_five_resources_and_disconnect(env, monkeypatch): assert denied.status_code == 403 deleted = await c.delete(f"/api/connector-instances/{instance_id}", headers=auth) assert deleted.status_code == 204 + assert revoke.await_count == int(oauth) gone = await c.post(path, params={"token": token}, json={"id": 2, "method": "tools/list"}) assert gone.status_code == 404 diff --git a/tests/unit/connectors/test_qcc.py b/tests/unit/connectors/test_qcc.py index 7905f2eec4..5b636c188f 100644 --- a/tests/unit/connectors/test_qcc.py +++ b/tests/unit/connectors/test_qcc.py @@ -1,4 +1,4 @@ -"""QCC catalog integration with API Key and the internal HTTP aggregator.""" +"""QCC catalog integration with OAuth and the internal HTTP aggregator.""" from __future__ import annotations @@ -36,9 +36,11 @@ async def test_qcc_credentials_build_and_probe(monkeypatch: pytest.MonkeyPatch) assert entry.mcp_mode == "internal" assert uses_internal_http_mcp(entry) assert not is_inprocess_gateway(entry) - assert get_mcp_oauth_remote("qcc") is None - creds = validate_create_credentials("qcc", {"api_key": "test-qcc-token"}) - assert creds["api_key"] == "test-qcc-token" + assert get_mcp_oauth_remote("qcc") is entry + creds = validate_create_credentials( + "qcc", {"access_token": "test-qcc-token", "oauth_client_id": "client"} + ) + assert creds["access_token"] == "test-qcc-token" assert creds["internal_token"] creds["internal_token"] = "local-gateway-token" spec = build_http_mcp_spec( @@ -54,7 +56,10 @@ async def test_qcc_credentials_build_and_probe(monkeypatch: pytest.MonkeyPatch) probe.assert_awaited_once_with("test-qcc-token") -def test_qcc_harness_config_uses_http_not_inprocess_gateway(tmp_path: Path) -> None: +@pytest.mark.parametrize( + "payload", [{"api_key": "k"}, {"access_token": "a", "oauth_client_id": "client"}] +) +def test_qcc_harness_config_uses_http_not_inprocess_gateway(tmp_path: Path, payload: dict) -> None: pool = SqlitePool(tmp_path / "octop.db") run_migrations(pool) with pool.transaction() as conn: @@ -75,7 +80,7 @@ def test_qcc_harness_config_uses_http_not_inprocess_gateway(tmp_path: Path) -> N settings_repo=SettingsRepo(pool), config=OctopConfig(), ) - svc.encrypt_and_store(instance_id="qcc1", payload={"api_key": "k", "internal_token": "tok"}) + svc.encrypt_and_store(instance_id="qcc1", payload={**payload, "internal_token": "tok"}) configs = build_mcp_server_configs_for_user( svc=svc, connector_repo=repo, diff --git a/tests/unit/connectors/test_qcc_grant.py b/tests/unit/connectors/test_qcc_grant.py index 096bbf9efa..101d6e0a89 100644 --- a/tests/unit/connectors/test_qcc_grant.py +++ b/tests/unit/connectors/test_qcc_grant.py @@ -260,3 +260,33 @@ async def test_metadata_mismatch_blocks_bearer_transport(monkeypatch): with pytest.raises(ValueError, match="metadata mismatch"): await qcc.request_resource("risk", "secret", "tools/list", {}) transport.assert_not_called() + + +@pytest.mark.asyncio +async def test_legacy_key_does_not_refresh_or_revoke(grant, monkeypatch): + _, repo, svc = grant + refresh = AsyncMock() + revoke = AsyncMock() + monkeypatch.setattr("octop.infra.connectors.service.refresh_oauth_credentials", refresh) + monkeypatch.setattr(qcc, "revoke", revoke) + error = httpx.HTTPStatusError( + "unauthorized", + request=httpx.Request("POST", qcc.RESOURCES["company"]), + response=httpx.Response(401), + ) + request = AsyncMock(side_effect=error) + monkeypatch.setattr(qcc, "request_resource", request) + with pytest.raises(httpx.HTTPStatusError): + await svc._qcc_request("grant", "company", "tools/list", {}) + request.assert_awaited_once() + refresh.assert_not_awaited() + await svc.disconnect_qcc("grant") + revoke.assert_not_awaited() + assert repo.get("grant") is None + + +def test_oauth_token_takes_precedence_over_legacy_key(): + assert ( + qcc.bearer_token({"api_key": "old", "access_token": "new", "oauth_client_id": "client"}) + == "new" + ) diff --git a/tests/unit/connectors/test_qcc_oauth_grant.py b/tests/unit/connectors/test_qcc_oauth_grant.py new file mode 100644 index 0000000000..21187d864d --- /dev/null +++ b/tests/unit/connectors/test_qcc_oauth_grant.py @@ -0,0 +1,429 @@ +"""Exercise the five real SDK transports with synthetic MCP HTTP responses.""" + +from __future__ import annotations + +import asyncio +import json +import time +from pathlib import Path +from unittest.mock import AsyncMock + +import httpx +import pytest + +from octop.config import OctopConfig +from octop.infra.connectors import qcc +from octop.infra.connectors.service import ConnectorService +from octop.infra.db.migrate import run_migrations +from octop.infra.db.pool import SqlitePool +from octop.infra.db.repos.connectors import ConnectorRepo +from octop.infra.db.repos.secrets import SecretRepo +from octop.infra.db.repos.settings import SettingsRepo + + +def service(pool: SqlitePool, repo: ConnectorRepo | None = None) -> ConnectorService: + return ConnectorService( + repo=repo or ConnectorRepo(pool), + secret_repo=SecretRepo(pool), + settings_repo=SettingsRepo(pool), + config=OctopConfig(), + ) + + +@pytest.fixture +def grant(tmp_path: Path): + pool = SqlitePool(tmp_path / "octop.db") + run_migrations(pool) + with pool.transaction() as conn: + conn.execute( + "INSERT INTO users(id,username,password_hash,role,created_at) VALUES (1,'qcc-test','x','user',1)" + ) + repo = ConnectorRepo(pool) + repo.create( + instance_id="grant", user_id=1, kind="qcc", display_name="QCC", mcp_server_name="qcc__grant" + ) + svc = service(pool, repo) + svc.encrypt_and_store( + instance_id="grant", + payload={ + "access_token": "old-access", + "refresh_token": "old-refresh", + "oauth_client_id": "public-client", + "expires_at": int(time.time()) + 3600, + }, + ) + return pool, repo, svc + + +@pytest.fixture +def mcp_http(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(qcc, "_metadata_ok_until", {}) + calls = [] + client_class = httpx.AsyncClient + + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) in qcc.RESOURCES.values() + body = json.loads(request.content) + calls.append((str(request.url), request.headers["Authorization"], body)) + method = body["method"] + if method.startswith("notifications/"): + return httpx.Response(202) + if method == "initialize": + result = { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "test", "version": "1"}, + } + elif method == "tools/list": + if body.get("params", {}).get("cursor") == "second": + result = {"tools": [{"name": "detail", "inputSchema": {"type": "object"}}]} + else: + result = { + "tools": [{"name": "lookup", "inputSchema": {"type": "object"}}], + "nextCursor": "second", + } + else: + assert method == "tools/call" + assert body["params"]["name"] == "lookup" + assert body["params"]["arguments"] == {"query": "example"} + result = {"content": [{"type": "text", "text": request.url.path}], "isError": False} + return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": result}) + + def factory(**kwargs): + assert kwargs["follow_redirects"] is False + assert kwargs["trust_env"] is False + return client_class(transport=httpx.MockTransport(handler), **kwargs) + + async def metadata(method, url, **kwargs): + assert method == "GET" + resource = url.split("/")[-2] + return httpx.Response( + 200, + request=httpx.Request("GET", url), + json={"resource": qcc.RESOURCES[resource], "authorization_servers": [qcc.ISSUER]}, + ) + + monkeypatch.setattr(qcc, "safe_request", metadata) + monkeypatch.setattr(qcc.httpx, "AsyncClient", factory) + return calls + + +@pytest.mark.asyncio +async def test_five_servers_list_paginate_and_call(grant, mcp_http): + _, _, svc = grant + listed = await svc.handle_qcc_request("grant", {"id": 1, "method": "tools/list"}) + names = [t["name"] for t in listed["result"]["tools"]] + assert len(names) == len(set(names)) == 10 + for resource in qcc.RESOURCES: + assert f"{resource}__detail" in names + result = await svc.handle_qcc_request( + "grant", + { + "id": 2, + "method": "tools/call", + "params": {"name": f"{resource}__lookup", "arguments": {"query": "example"}}, + }, + ) + assert result["result"]["content"][0]["text"] == f"/mcp/{resource}/stream" + assert {url for url, _, _ in mcp_http} == set(qcc.RESOURCES.values()) + assert {auth for _, auth, _ in mcp_http} == {"Bearer old-access"} + + +@pytest.mark.asyncio +async def test_concurrent_refresh_across_service_instances(grant, monkeypatch): + pool, repo, svc = grant + creds = svc.decrypt("grant") + creds["expires_at"] = 1 + svc.encrypt_and_store(instance_id="grant", payload=creds) + entered = asyncio.Event() + release = asyncio.Event() + + async def refresh(**kwargs): + assert kwargs["creds"]["refresh_token"] == "old-refresh" + entered.set() + await release.wait() + return { + "access_token": "new-access", + "refresh_token": "new-refresh", + "expires_at": int(time.time()) + 3600, + } + + mocked = AsyncMock(side_effect=refresh) + monkeypatch.setattr("octop.infra.connectors.service.refresh_oauth_credentials", mocked) + tasks = [ + asyncio.create_task(service(pool, repo).ensure_fresh_credentials("grant", "qcc")) + for _ in range(10) + ] + await entered.wait() + release.set() + results = await asyncio.gather(*tasks) + assert mocked.await_count == 1 + assert all(r["access_token"] == "new-access" for r in results) + assert svc.decrypt("grant")["refresh_token"] == "new-refresh" + assert b"new-refresh" not in repo.get("grant").credential_blob + + +@pytest.mark.asyncio +async def test_restart_restores_rotated_grant(grant, mcp_http): + pool, _, svc = grant + creds = svc.decrypt("grant") + stable_token = creds["internal_token"] + creds.update(access_token="rotated-access", refresh_token="rotated-refresh") + svc.encrypt_and_store(instance_id="grant", payload=creds) + # Reopen the on-disk DB with new repositories and no in-memory OAuth state. + restarted = service(SqlitePool(pool.path)) + assert restarted.decrypt("grant")["internal_token"] == stable_token + assert restarted.decrypt("grant")["refresh_token"] == "rotated-refresh" + result = await restarted.handle_qcc_request("grant", {"id": 1, "method": "tools/list"}) + assert len(result["result"]["tools"]) == 10 + assert {auth for _, auth, _ in mcp_http} == {"Bearer rotated-access"} + + +@pytest.mark.asyncio +async def test_disconnect_revokes_latest_refresh_and_removes_all_servers(grant, monkeypatch): + _, repo, svc = grant + token = svc.decrypt("grant")["internal_token"] + revoke = AsyncMock() + monkeypatch.setattr(qcc, "revoke", revoke) + await svc.disconnect_qcc("grant") + assert revoke.await_args.args[0]["refresh_token"] == "old-refresh" + assert repo.get("grant") is None + assert svc.verify_internal_token("grant", token) is None + assert await svc.mcp_configs_for_user(1) == {} + request = AsyncMock() + monkeypatch.setattr(qcc, "request_resource", request) + result = await svc.handle_qcc_request("grant", {"id": 1, "method": "tools/list"}) + assert "error" in result + request.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_failed_revoke_keeps_grant_for_retry(grant, monkeypatch): + _, repo, svc = grant + monkeypatch.setattr(qcc, "revoke", AsyncMock(side_effect=ValueError("offline"))) + with pytest.raises(ValueError, match="retry disconnect"): + await svc.disconnect_qcc("grant") + assert repo.get("grant") is not None + + +@pytest.mark.asyncio +async def test_401_refreshes_once_and_retries(grant, monkeypatch): + _, _, svc = grant + exc = httpx.HTTPStatusError( + "unauthorized", + request=httpx.Request("POST", qcc.RESOURCES["risk"]), + response=httpx.Response(401), + ) + request = AsyncMock(side_effect=[exc, {"content": [], "isError": False}]) + refresh = AsyncMock( + return_value={ + "access_token": "new-access", + "refresh_token": "new-refresh", + "expires_at": int(time.time()) + 3600, + } + ) + monkeypatch.setattr(qcc, "request_resource", request) + monkeypatch.setattr("octop.infra.connectors.service.refresh_oauth_credentials", refresh) + result = await svc.handle_qcc_request( + "grant", {"id": 1, "method": "tools/call", "params": {"name": "risk__lookup"}} + ) + assert "result" in result + assert [c.args[1] for c in request.await_args_list] == ["old-access", "new-access"] + assert refresh.await_count == 1 + + +@pytest.mark.asyncio +async def test_unknown_resource_never_receives_token(grant, monkeypatch): + _, _, svc = grant + request = AsyncMock() + monkeypatch.setattr(qcc, "request_resource", request) + result = await svc.handle_qcc_request( + "grant", {"id": 1, "method": "tools/call", "params": {"name": "evil__lookup"}} + ) + assert "error" in result + request.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_disconnect_waits_for_rotation_without_resurrecting_grant(grant, monkeypatch): + pool, repo, svc = grant + creds = svc.decrypt("grant") + creds["expires_at"] = 1 + svc.encrypt_and_store(instance_id="grant", payload=creds) + entered, release = asyncio.Event(), asyncio.Event() + + async def refresh(**kwargs): + entered.set() + await release.wait() + return { + "access_token": "new", + "refresh_token": "rotated", + "expires_at": int(time.time()) + 3600, + } + + monkeypatch.setattr("octop.infra.connectors.service.refresh_oauth_credentials", refresh) + revoke = AsyncMock() + monkeypatch.setattr(qcc, "revoke", revoke) + rotating = asyncio.create_task(svc.ensure_fresh_credentials("grant", "qcc")) + await entered.wait() + deleting = asyncio.create_task(service(pool, repo).disconnect_qcc("grant")) + await asyncio.sleep(0) + revoke.assert_not_awaited() + release.set() + await asyncio.gather(rotating, deleting) + assert revoke.await_args.args[0]["refresh_token"] == "rotated" + assert repo.get("grant") is None + assert await svc.ensure_fresh_credentials("grant", "qcc") == {} + + +@pytest.mark.asyncio +async def test_repeated_401_stops_after_one_retry(grant, monkeypatch): + _, _, svc = grant + exc = httpx.HTTPStatusError( + "secret-in-error", + request=httpx.Request("POST", qcc.RESOURCES["risk"]), + response=httpx.Response(401), + ) + request = AsyncMock(side_effect=exc) + refresh = AsyncMock(return_value={"access_token": "new", "expires_at": int(time.time()) + 3600}) + monkeypatch.setattr(qcc, "request_resource", request) + monkeypatch.setattr("octop.infra.connectors.service.refresh_oauth_credentials", refresh) + result = await svc.handle_qcc_request( + "grant", {"id": 1, "method": "tools/call", "params": {"name": "risk__lookup"}} + ) + assert "error" in result and "secret-in-error" not in json.dumps(result) + assert request.await_count == 2 + assert refresh.await_count == 1 + + +@pytest.mark.asyncio +async def test_probe_reports_partial_failure(monkeypatch): + async def request(resource, *args): + if resource == "risk": + raise ValueError("secret") + return {"tools": [{"name": "lookup"}]} + + monkeypatch.setattr(qcc, "request_resource", request) + result = await qcc.probe("synthetic-token") + assert result["ok"] is True + assert result["tool_count"] == 4 + assert result["servers"]["risk"] == {"ok": False} + assert len(result["servers"]) == 5 + assert "secret" not in json.dumps(result) + + +@pytest.mark.asyncio +async def test_revoke_discovers_validates_and_posts_refresh_token(monkeypatch): + endpoint = qcc.ISSUER + "/oauth/revoke" + metadata = AsyncMock(return_value={"revocation_endpoint": endpoint}) + validate = AsyncMock(return_value=endpoint) + request = AsyncMock(return_value=httpx.Response(200, request=httpx.Request("POST", endpoint))) + monkeypatch.setattr(qcc, "fetch_authorization_metadata", metadata) + monkeypatch.setattr(qcc, "_ensure_mcp_oauth_url", validate) + monkeypatch.setattr(qcc, "safe_request", request) + await qcc.revoke({"refresh_token": "latest-refresh", "oauth_client_id": "client"}) + metadata.assert_awaited_once_with(qcc.ISSUER) + validate.assert_awaited_once_with(endpoint, issuer=qcc.ISSUER, field="revocation_endpoint") + assert request.await_args.kwargs["data"] == { + "client_id": "client", + "token": "latest-refresh", + "token_type_hint": "refresh_token", + } + validate.side_effect = ValueError("untrusted endpoint") + request.reset_mock() + with pytest.raises(ValueError): + await qcc.revoke({"refresh_token": "latest-refresh"}) + request.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_metadata_mismatch_blocks_bearer_transport(monkeypatch): + response = httpx.Response( + 200, + request=httpx.Request("GET", qcc.ISSUER), + json={"resource": "https://example.com/mcp", "authorization_servers": [qcc.ISSUER]}, + ) + monkeypatch.setattr(qcc, "safe_request", AsyncMock(return_value=response)) + transport = AsyncMock() + monkeypatch.setattr(qcc, "streamable_http_client", transport) + with pytest.raises(ValueError, match="metadata mismatch"): + await qcc.request_resource("risk", "secret", "tools/list", {}) + transport.assert_not_called() + + +@pytest.mark.asyncio +async def test_concurrent_401_requests_share_rotated_token(grant, monkeypatch): + pool, repo, _ = grant + arrived = 0 + all_arrived = asyncio.Event() + + async def request(resource, token, method, params): + nonlocal arrived + if token == "old-access": + arrived += 1 + if arrived == 5: + all_arrived.set() + await all_arrived.wait() + raise httpx.HTTPStatusError( + "expired", + request=httpx.Request("POST", qcc.RESOURCES[resource]), + response=httpx.Response(401), + ) + assert token == "new-access" + return {"content": []} + + refresh = AsyncMock( + return_value={ + "access_token": "new-access", + "refresh_token": "new-refresh", + "expires_at": int(time.time()) + 3600, + } + ) + monkeypatch.setattr(qcc, "request_resource", request) + monkeypatch.setattr("octop.infra.connectors.service.refresh_oauth_credentials", refresh) + results = await asyncio.gather( + *( + service(pool, repo)._qcc_request("grant", resource, "tools/call", {"name": "lookup"}) + for resource in qcc.RESOURCES + ) + ) + assert all(result == {"content": []} for result in results) + refresh.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_other_remote_oauth_keeps_refresh_path(grant, monkeypatch): + _, repo, svc = grant + repo.create( + instance_id="notion", + user_id=1, + kind="notion", + display_name="Notion", + mcp_server_name="notion__one", + ) + svc.encrypt_and_store( + instance_id="notion", + payload={"access_token": "expired", "refresh_token": "refresh", "expires_at": 1}, + ) + refresh = AsyncMock(return_value={"access_token": "new", "expires_at": 9999999999}) + qcc_refresh = AsyncMock(side_effect=AssertionError("QCC branch must not run")) + monkeypatch.setattr("octop.infra.connectors.service.refresh_oauth_credentials", refresh) + monkeypatch.setattr(svc, "_fresh_qcc", qcc_refresh) + result = await svc.ensure_fresh_credentials("notion", "notion") + assert result["access_token"] == "new" + assert refresh.await_args.kwargs["kind"] == "notion" + qcc_refresh.assert_not_awaited() + + +def test_legacy_key_migration_preserves_internal_url(grant): + _, _, svc = grant + svc.encrypt_and_store(instance_id="grant", payload={"api_key": "legacy"}) + original = svc.decrypt("grant")["internal_token"] + svc.encrypt_and_store( + instance_id="grant", + payload={"access_token": "new", "refresh_token": "refresh", "oauth_client_id": "client"}, + ) + migrated = svc.decrypt("grant") + assert migrated["internal_token"] == original + assert "api_key" not in migrated + assert qcc.bearer_token(migrated) == "new" diff --git a/tests/unit/connectors/test_qcc_oauth_registry.py b/tests/unit/connectors/test_qcc_oauth_registry.py new file mode 100644 index 0000000000..807401faff --- /dev/null +++ b/tests/unit/connectors/test_qcc_oauth_registry.py @@ -0,0 +1,85 @@ +"""QCC catalog integration with the shared MCP OAuth and probe flows.""" + +from __future__ import annotations + +import hashlib +from base64 import urlsafe_b64encode +from unittest.mock import AsyncMock +from urllib.parse import parse_qs, urlparse + +import pytest + +from octop.infra.connectors.oauth import registry + +ISSUER = "https://agent.qcc.com" +RESOURCE = f"{ISSUER}/mcp/company/stream" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("metadata_scopes", [None, ["mcp:tools"]]) +@pytest.mark.parametrize( + "base_url", ["http://127.0.0.1:8088", "http://localhost:8088", "https://octop.example.com"] +) +async def test_qcc_catalog_authorization_parameters( + monkeypatch: pytest.MonkeyPatch, metadata_scopes: list[str] | None, base_url: str +) -> None: + metadata = { + "authorization_endpoint": f"{ISSUER}/oauth/authorize", + "token_endpoint": f"{ISSUER}/oauth/token", + "registration_endpoint": f"{ISSUER}/oauth/register", + "token_endpoint_auth_methods_supported": ["none"], + "scopes_supported": metadata_scopes, + } + fetch = AsyncMock(return_value=metadata) + register = AsyncMock(return_value={"client_id": "test-public-client"}) + monkeypatch.setattr(registry, "fetch_authorization_metadata", fetch) + monkeypatch.setattr(registry, "register_dynamic_client", register) + callback = base_url + "/api/connectors/oauth/callback" + url, verifier, ctx = await registry.start_oauth_for_target( + target={"type": "catalog", "kind": "qcc"}, + redirect_uri=callback, + state="test-state", + settings_repo=None, + ) + fetch.assert_awaited_once_with(ISSUER) + assert register.await_args.kwargs["redirect_uri"] == callback + query = parse_qs(urlparse(url).query) + assert url.startswith(f"{ISSUER}/oauth/authorize?") + assert query["client_id"] == ["test-public-client"] + assert query["redirect_uri"] == [callback] + assert query["scope"] == ["mcp:tools"] + assert query["resource"] == [RESOURCE] + assert query["state"] == ["test-state"] + assert query["code_challenge_method"] == ["S256"] + challenge = urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=") + assert query["code_challenge"] == [challenge.decode()] + assert ctx["issuer"] == ISSUER + assert ctx["resource"] == RESOURCE + + +@pytest.mark.asyncio +async def test_qcc_refresh_uses_public_client_and_company_resource( + monkeypatch: pytest.MonkeyPatch, +) -> None: + metadata = {"token_endpoint": f"{ISSUER}/oauth/token"} + fetch = AsyncMock(return_value=metadata) + refresh = AsyncMock( + return_value={"access_token": "new-access", "refresh_token": "rotated-refresh"} + ) + monkeypatch.setattr(registry, "fetch_authorization_metadata", fetch) + monkeypatch.setattr(registry, "refresh_access_token", refresh) + result = await registry.refresh_oauth_credentials( + kind="qcc", + creds={"oauth_client_id": "test-public-client", "refresh_token": "old-refresh"}, + settings_repo=None, + ) + fetch.assert_awaited_once_with(ISSUER) + refresh.assert_awaited_once_with( + metadata, + issuer=ISSUER, + client_id="test-public-client", + client_secret=None, + refresh_token="old-refresh", + resource=RESOURCE, + ) + assert result["refresh_token"] == "rotated-refresh" From 347dee56f46c3ff4fede0222c82b56f559b7e7a3 Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Fri, 25 Sep 2026 22:32:41 +0800 Subject: [PATCH 018/126] refactor(infra): group agents modules and clarify package ownership (#1164) * refactor(agents): group thin modules into domain subpackages Move plugin helpers, memory, settings stores, persona/MBTI, and avatar bootstrap code under plugins/, memory/, settings/, persona/, and experts/ while keeping top-level import shims so existing call sites stay stable. Co-authored-by: Cursor * refactor(agents): drop compatibility shims after subpackage move Call sites already import the new package paths; remove the top-level re-export stubs so the agents tree only shows real modules. Co-authored-by: Cursor * refactor(agents): move thread helpers into threads/ Group artifact shaping, fork, and context breakdown under agents/threads so top-level only keeps lifecycle/config adapters. Co-authored-by: Cursor * refactor(agents): fold profile/runtime/tool catalog into settings/ Move the remaining config-surface helpers under settings/ and drop the skill_package id-list aliases in favor of dump/parse_id_list. Co-authored-by: Cursor * refactor(agents): group workspace_dir and execute_env under workspace/ Keep path resolution and harness execute-env injection together as one workspace package; leave only manager and conversation_mode at top level. Co-authored-by: Cursor * refactor(infra): fix history/SkillHub ownership and drop thin providers/ Move history_projection under history/, share SkillHub host/limits and route expert skill downloads through skills.skillhub_market, and fold Codex OAuth helpers into agents.providers to remove the tiny providers/ package. Co-authored-by: Cursor * docs: sync AGENTS.md and guides with infra/agents layout Reflect subpackages (settings/workspace/threads/persona, history/, skills/) and drop stale runtime.py / mbti_profiles paths after the agents reorg. Co-authored-by: Cursor --------- Co-authored-by: jubaoliang Co-authored-by: Cursor --- AGENTS.md | 6 +- docs/agent-backend-file-io.md | 2 +- docs/api.md | 2 +- docs/memory-slim.md | 2 +- docs/personas.md | 16 ++--- src/octop/api/common/agent_runtime.py | 2 +- src/octop/api/common/memory_client.py | 4 +- src/octop/api/routers/agent_tools.py | 8 +-- src/octop/api/routers/agents.py | 10 +-- src/octop/api/routers/chat/history.py | 8 +-- src/octop/api/routers/chat/routes.py | 2 +- src/octop/api/routers/chat/serialize.py | 4 +- src/octop/api/routers/experts.py | 2 +- src/octop/api/routers/invites.py | 2 +- src/octop/api/routers/mbti.py | 2 +- src/octop/api/routers/media_generation.py | 2 +- src/octop/api/routers/memory_portable.py | 2 +- src/octop/api/routers/plugins.py | 4 +- src/octop/api/routers/providers.py | 28 ++++----- src/octop/api/routers/setup.py | 2 +- src/octop/cli/commands/memory.py | 2 +- src/octop/cli/support/offline_ops.py | 2 +- src/octop/infra/__init__.py | 39 +++++++----- .../infra/agents/{ => experts}/avatar.py | 0 src/octop/infra/agents/experts/catalog.py | 2 +- .../agents/{ => experts}/default_agent.py | 0 .../infra/agents/experts/market_creation.py | 2 +- src/octop/infra/agents/experts/publish.py | 2 +- .../agents/experts/published_creation.py | 2 +- .../infra/agents/experts/skillhub_market.py | 62 +++++++++++++----- src/octop/infra/agents/manager.py | 63 ++++++++++--------- src/octop/infra/agents/memory/__init__.py | 21 +++++++ .../{memory_backend.py => memory/backend.py} | 2 +- .../agents/{memory_slim.py => memory/slim.py} | 0 .../slim_control.py} | 0 .../agents/middleware/thread_artifacts.py | 2 +- src/octop/infra/agents/persona/__init__.py | 21 +++++++ .../agents/{persona.py => persona/loader.py} | 2 +- .../agents/{ => persona}/mbti_profiles.py | 0 src/octop/infra/agents/plugins/__init__.py | 22 ++++++- src/octop/infra/agents/plugins/manager.py | 2 +- .../{ => plugins}/plugin_tool_defaults.py | 0 .../agents/{ => plugins}/plugin_tool_names.py | 0 .../{ => agents}/providers/codex_apply.py | 2 +- .../{ => agents}/providers/codex_oauth.py | 0 src/octop/infra/agents/settings/__init__.py | 29 +++++++++ .../{acp_settings.py => settings/acp.py} | 0 .../infra/agents/{ => settings}/langfuse.py | 0 .../agents/{ => settings}/media_generation.py | 0 .../infra/agents/{ => settings}/profile.py | 13 +--- .../agents/{ => settings}/runtime_limits.py | 0 .../agents/{ => settings}/tool_catalog.py | 0 src/octop/infra/agents/teams/service.py | 2 +- src/octop/infra/agents/teams/team_manager.py | 4 +- src/octop/infra/agents/teams/welcome.py | 2 +- src/octop/infra/agents/threads/__init__.py | 29 +++++++++ .../artifact.py} | 0 .../agents/{ => threads}/context_breakdown.py | 0 .../{thread_fork.py => threads/fork.py} | 2 +- src/octop/infra/agents/workspace/__init__.py | 21 +++++++ .../{workspace_dir.py => workspace/dir.py} | 0 .../agents/{ => workspace}/execute_env.py | 2 +- src/octop/infra/backup/chats.py | 2 +- src/octop/infra/backup/system_archive.py | 2 +- src/octop/infra/cron/delivery.py | 2 +- src/octop/infra/db/migrate.py | 2 +- .../infra/gateway/media/inbound_store.py | 2 +- src/octop/infra/gateway/process/processor.py | 10 +-- .../infra/gateway/process/stream_project.py | 2 +- src/octop/infra/history/legacy.py | 2 +- .../projection.py} | 0 src/octop/infra/history/recorder.py | 14 ++--- src/octop/infra/skills/skillhub_common.py | 24 +++++++ src/octop/infra/skills/skillhub_market.py | 19 ++++-- src/octop/infra/skills/workspace_catalog.py | 2 +- src/octop/launch.py | 2 +- tests/integration/test_invites_api.py | 2 +- .../integration/test_media_generation_api.py | 2 +- tests/integration/test_provider_test_draft.py | 2 +- tests/live/test_agent_expert_template_live.py | 2 +- tests/unit/agents/test_acp_settings.py | 2 +- tests/unit/agents/test_agent_avatar.py | 2 +- tests/unit/agents/test_agent_manager.py | 2 +- tests/unit/agents/test_context_breakdown.py | 2 +- tests/unit/agents/test_default_agent.py | 4 +- tests/unit/agents/test_execute_env.py | 2 +- .../agents/test_media_generation_settings.py | 2 +- tests/unit/agents/test_memory_backend.py | 2 +- tests/unit/agents/test_memory_slim.py | 6 +- tests/unit/agents/test_persona.py | 2 +- .../unit/agents/test_plugin_tool_defaults.py | 2 +- tests/unit/agents/test_runtime_limits.py | 2 +- tests/unit/agents/test_thread_artifacts.py | 4 +- tests/unit/agents/test_thread_fork.py | 2 +- tests/unit/agents/test_tool_catalog.py | 4 +- tests/unit/agents/test_workspace_dir.py | 2 +- tests/unit/db/test_agent_profile_columns.py | 2 +- .../gateway/test_processor_resource_errors.py | 2 +- tests/unit/gateway/test_versioned_history.py | 2 +- .../test_projection.py} | 12 ++-- tests/unit/providers/test_codex_oauth.py | 4 +- tests/unit/test_langfuse_settings.py | 2 +- tests/unit/test_plugins.py | 4 +- 103 files changed, 416 insertions(+), 211 deletions(-) rename src/octop/infra/agents/{ => experts}/avatar.py (100%) rename src/octop/infra/agents/{ => experts}/default_agent.py (100%) create mode 100644 src/octop/infra/agents/memory/__init__.py rename src/octop/infra/agents/{memory_backend.py => memory/backend.py} (98%) rename src/octop/infra/agents/{memory_slim.py => memory/slim.py} (100%) rename src/octop/infra/agents/{memory_slim_control.py => memory/slim_control.py} (100%) create mode 100644 src/octop/infra/agents/persona/__init__.py rename src/octop/infra/agents/{persona.py => persona/loader.py} (96%) rename src/octop/infra/agents/{ => persona}/mbti_profiles.py (100%) rename src/octop/infra/agents/{ => plugins}/plugin_tool_defaults.py (100%) rename src/octop/infra/agents/{ => plugins}/plugin_tool_names.py (100%) rename src/octop/infra/{ => agents}/providers/codex_apply.py (97%) rename src/octop/infra/{ => agents}/providers/codex_oauth.py (100%) create mode 100644 src/octop/infra/agents/settings/__init__.py rename src/octop/infra/agents/{acp_settings.py => settings/acp.py} (100%) rename src/octop/infra/agents/{ => settings}/langfuse.py (100%) rename src/octop/infra/agents/{ => settings}/media_generation.py (100%) rename src/octop/infra/agents/{ => settings}/profile.py (91%) rename src/octop/infra/agents/{ => settings}/runtime_limits.py (100%) rename src/octop/infra/agents/{ => settings}/tool_catalog.py (100%) create mode 100644 src/octop/infra/agents/threads/__init__.py rename src/octop/infra/agents/{thread_artifact.py => threads/artifact.py} (100%) rename src/octop/infra/agents/{ => threads}/context_breakdown.py (100%) rename src/octop/infra/agents/{thread_fork.py => threads/fork.py} (99%) create mode 100644 src/octop/infra/agents/workspace/__init__.py rename src/octop/infra/agents/{workspace_dir.py => workspace/dir.py} (100%) rename src/octop/infra/agents/{ => workspace}/execute_env.py (98%) rename src/octop/infra/{gateway/process/history_projection.py => history/projection.py} (100%) create mode 100644 src/octop/infra/skills/skillhub_common.py rename tests/unit/{gateway/test_history_projection.py => history/test_projection.py} (99%) diff --git a/AGENTS.md b/AGENTS.md index 6cbd206eef..c6ffc520e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,13 +107,15 @@ cli/ ──► launch.py ──► api/ + infra/ | Path | Owns | Typical importers | |------|------|-------------------| -| `infra/agents/` | Agent registry (`manager.py`), harness runtime, provider store (`providers/`), settings stores (`security/`, `acp_settings`, `langfuse`), MBTI personas, expert catalog (`experts/`) | `server.py`, `gateway/`, `api/routers/agents.py` | +| `infra/agents/` | Agent registry (`manager.py`), harness runtime, providers, settings (`settings/`), workspace paths (`workspace/`), threads helpers (`threads/`), security, persona/MBTI, experts, plugins, teams, memory | `server.py`, `gateway/`, `api/routers/agents.py` | | `infra/backend/` | Workspace storage adapter, resolver, remote probe (COS/S3/…) | `agents/`, `api/routers/workspace*.py` | | `infra/connectors/` | Connector catalog, OAuth, MCP gateway, credential crypto | `api/routers/connectors.py`, `internal_mcp.py`, `agents/manager.py` (MCP assembly) | | `infra/cron/` | Cron jobs, triggers, agent tool hooks | `server.py`, `api/routers/cron.py` | | `infra/db/` | `SqlitePool`, migrations, `RepoBundle` / `SharedServices` in `services.py` | all domain code needing persistence | | `infra/gateway/` | IM ingress (`processor.py`), threads, slash commands (`slash/`), bot setup (`bot_creators/`) | `server.py`, `api/routers/chat.py`, `channels.py` | +| `infra/history/` | Versioned message archive, trajectory, turn projection (`projection.py`) | `gateway/`, `api/routers/chat`, `cron/`, `agents/teams` | | `infra/setup/` | First-run wizard, system service install, TLS / Let's Encrypt | `server.py`, `launch.py`, `api/routers/setup.py`, `api/routers/tls.py` | +| `infra/skills/` | Skill packages, SkillHub HTTP client (`skillhub_market`, `skillhub_common`) | `api/routers/skills.py`, `agents/experts` | | `infra/users/` | Users, roles, password hashing, `UserManager` | `server.py`, `api/routers/auth.py`, `users.py` | | `infra/errors.py` | `OctopError`, `ErrorCode` — shared exception types | everywhere in `infra/` and `api/` | | `infra/metrics.py` | In-process counters (`METRICS`) | lazy-import inside hot paths | @@ -356,7 +358,7 @@ Boundary rules are in [§5](#5-module-boundaries). Additionally: | TLS / Let's Encrypt | `infra/setup/tls/`, `api/routers/tls.py` | | `octop run` boot sequence | `launch.py`, `cli/run_cmd.py` | | How is a message processed? | `infra/gateway/processor.py` → harness agent | -| How are agents started/stopped? | `infra/agents/manager.py`, `infra/agents/runtime.py` | +| How are agents started/stopped? | `infra/agents/manager.py` | | How does cron work? | `infra/cron/manager.py`, `infra/cron/job.py` | | What DB tables exist? | `infra/db/migrations/` + `infra/db/repos/` | | What env vars are supported? | `config.py` | diff --git a/docs/agent-backend-file-io.md b/docs/agent-backend-file-io.md index a5ee2cbc28..0a1e64e020 100644 --- a/docs/agent-backend-file-io.md +++ b/docs/agent-backend-file-io.md @@ -108,7 +108,7 @@ await backend.aupload_files([(soul_path, text.encode("utf-8"))]) | 位置 | 现状 | 改造 | |------|------|------| | `infra/agents/manager.py` `_seed_workspace` | `init_workspace(ws_dir)` 写本地 | `init_workspace(tmp_dir)` → 收集文件 → `backend.aupload_files`,path 为 `str(workspace_dir / rel)` | -| `infra/agents/persona.py` `write_soul_md` | `Path.write_text` | 改为 async,参数含 `backend`;`aupload_files([(str(workspace_dir / "SOUL.md"), ...)])` | +| `infra/agents/persona/` (SOUL / persona render) | `Path.write_text` | 改为 async,参数含 `backend`;`aupload_files([(str(workspace_dir / "SOUL.md"), ...)])` | | `infra/agents/manager.py` `_start_agent` | 调 `write_soul_md(workspace_dir=...)` | 先 `resolve_harness_backend`,再写 SOUL | | `infra/agents/plugins/manager.py` `sync_skills_to_workspace` | `shutil.copytree` 到本地 | 重命名为 `sync_skills_to_backend`;遍历插件目录 → `aupload_files`,path 为 `str(workspace_dir / "skills" / name / ...)` | | `infra/agents/manager.py` `_apply_expert_template` | 本地 `write_text` + 可选 `aupload_files` | **删除本地写**;path 用 `str(ws / rel_path.lstrip("/"))`,与原先 `disk_path` 一致 | diff --git a/docs/api.md b/docs/api.md index 658bdcb910..1ea86799a4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -277,7 +277,7 @@ reloads running agents so the image and video tools receive the new configuratio | `GET` | `/personas` | user | `[{code}, ...]` (compat shim) | | `GET` | `/personas/{code}` | user | rendered template (compat shim) | -Persona content lives in `src/octop/infra/agents/mbti_profiles.py` — +Persona content lives in `src/octop/infra/agents/persona/mbti_profiles.py` — see [Personas](./personas.md). ## Experts diff --git a/docs/memory-slim.md b/docs/memory-slim.md index 3820b3473b..38f9e82bad 100644 --- a/docs/memory-slim.md +++ b/docs/memory-slim.md @@ -151,7 +151,7 @@ strict mypy 499 files、Ruff/format 通过。这里只验证 PostgreSQL 拒绝 2026-09-18 对话入口补充: ```bash -PYTHONPATH=../octop-memory/src:src uv run --no-sync pytest tests/unit/agents/test_memory_slim.py tests/unit/gateway/test_slash*.py tests/unit/gateway/test_message_keys.py tests/unit/gateway/test_gateway.py tests/unit/gateway/test_history_projection.py tests/unit/i18n -q +PYTHONPATH=../octop-memory/src:src uv run --no-sync pytest tests/unit/agents/test_memory_slim.py tests/unit/gateway/test_slash*.py tests/unit/gateway/test_message_keys.py tests/unit/gateway/test_gateway.py tests/unit/history/test_projection.py tests/unit/i18n -q ``` 178 passed,覆盖真实临时 SQLite 的对话触发、历史保留、用户隔离、批次串行/互斥、所有权变更、 diff --git a/docs/personas.md b/docs/personas.md index 0a0625573b..45018a02f3 100644 --- a/docs/personas.md +++ b/docs/personas.md @@ -2,13 +2,13 @@ Each agent has an optional MBTI persona (`persona_mbti` column on the `agents` row). The persona profile is rendered into the agent's -`SOUL.md` (via `octop.infra.agents.persona.render_soul_template`) at -boot. If the user also sets a `system_prompt` on the agent row, it is -**appended** to the persona — the persona file is the backbone, the -user prompt is the trim. +`SOUL.md` (via `octop.infra.agents.persona.render_persona_template` / +`PersonaLoader`) at boot. If the user also sets a `system_prompt` on the +agent row, it is **appended** to the persona — the persona file is the +backbone, the user prompt is the trim. If `persona_mbti` is `NULL` or the empty string, octop falls back to -the built-in `_DEFAULT_SOUL_TEMPLATE` (warm, direct, competent tone). +the built-in `_DEFAULT_PERSONA_TEMPLATE` (warm, direct, competent tone). ## Where the 16 codes live @@ -16,9 +16,9 @@ Persona content is now data, not files: | Resource | Location | |----------|----------| -| 16 `MBTIProfile` records | [`src/octop/infra/agents/mbti_profiles.py`](../src/octop/infra/agents/mbti_profiles.py) | -| Public API (`get_profile`, `get_all_profiles`, `MBTIProfile`, `MBTIDimensions`, `MBTIBehaviorMapping`) | same module | -| SOUL.md rendering | [`src/octop/infra/agents/persona.py`](../src/octop/infra/agents/persona.py) | +| 16 `MBTIProfile` records | [`src/octop/infra/agents/persona/mbti_profiles.py`](../src/octop/infra/agents/persona/mbti_profiles.py) | +| Public API (`get_profile`, `get_all_profiles`, `MBTIProfile`, …) | same module (also re-exported from `octop.infra.agents.persona`) | +| SOUL.md / persona rendering | [`src/octop/infra/agents/persona/loader.py`](../src/octop/infra/agents/persona/loader.py) | | Persisted agent field | `agents.persona_mbti` (string, e.g. `"INTJ"`) | The legacy `src/octop/infra/agents/personas/*.md` directory is gone — diff --git a/src/octop/api/common/agent_runtime.py b/src/octop/api/common/agent_runtime.py index 70847a6d7f..a14a66e26a 100644 --- a/src/octop/api/common/agent_runtime.py +++ b/src/octop/api/common/agent_runtime.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field -from octop.infra.agents.runtime_limits import AGENT_RUNTIME_CONFIG_KEYS +from octop.infra.agents.settings.runtime_limits import AGENT_RUNTIME_CONFIG_KEYS class AgentRuntimeFields(BaseModel): diff --git a/src/octop/api/common/memory_client.py b/src/octop/api/common/memory_client.py index 119f308ade..367fdc22cd 100644 --- a/src/octop/api/common/memory_client.py +++ b/src/octop/api/common/memory_client.py @@ -14,8 +14,8 @@ from typing import Any from octop.api.common.agent import require_agent_owner_row -from octop.infra.agents.memory_backend import open_memory_kwargs -from octop.infra.agents.workspace_dir import host_system_dir +from octop.infra.agents.memory.backend import open_memory_kwargs +from octop.infra.agents.workspace.dir import host_system_dir from octop.infra.errors import ErrorCode, OctopError logger = logging.getLogger(__name__) diff --git a/src/octop/api/routers/agent_tools.py b/src/octop/api/routers/agent_tools.py index 82b0126c20..5b628b1a00 100644 --- a/src/octop/api/routers/agent_tools.py +++ b/src/octop/api/routers/agent_tools.py @@ -10,15 +10,15 @@ from octop.api.common.agent import assert_agent_owner as _assert_agent_owner from octop.api.deps import current_user, get_server from octop.i18n.domains.tools import tool_display_name -from octop.infra.agents.plugin_tool_defaults import merge_plugins_tool_settings -from octop.infra.agents.teams import HOST_TOOLS_ALLOWED, is_team_agent -from octop.infra.agents.tool_catalog import ( +from octop.infra.agents.plugins.plugin_tool_defaults import merge_plugins_tool_settings +from octop.infra.agents.settings.tool_catalog import ( BUILTIN_TOOL_CATALOG, CRITICAL_TOOLS, agent_plugin_enabled, builtin_tool_available, normalize_tools_disabled, ) +from octop.infra.agents.teams import HOST_TOOLS_ALLOWED, is_team_agent from octop.infra.errors import ErrorCode, OctopError from octop.infra.server import OctopServer from octop.infra.utils.locale import resolve_request_locale @@ -82,7 +82,7 @@ def _plugin_tool_label( if labeled != name: return labeled if description: - from octop.infra.agents.plugin_tool_names import ( # noqa: PLC0415 + from octop.infra.agents.plugins.plugin_tool_names import ( # noqa: PLC0415 extract_original_plugin_label, ) diff --git a/src/octop/api/routers/agents.py b/src/octop/api/routers/agents.py index e2ae901fe9..2ea02c901b 100644 --- a/src/octop/api/routers/agents.py +++ b/src/octop/api/routers/agents.py @@ -14,21 +14,21 @@ from octop.api.common.validators import assert_user_backend_root_dirs from octop.api.common.workspace import require_agent_workspace from octop.api.deps import current_user, get_server -from octop.infra.agents.avatar import ( +from octop.infra.agents.experts.avatar import ( agent_avatar_api_path, delete_workspace_avatar, display_agent_icon_url, read_workspace_avatar, write_workspace_avatar, ) -from octop.infra.agents.profile import ( +from octop.infra.agents.settings.profile import ( id_list_from_row, parse_config_json, - parse_skill_package_ids_json, + parse_id_list_json, strip_profile_config, welcome_from_row, ) -from octop.infra.agents.runtime_limits import ( +from octop.infra.agents.settings.runtime_limits import ( AGENT_RUNTIME_CONFIG_KEYS, agent_runtime_values, ) @@ -150,7 +150,7 @@ def _row_dict( for key, value in strip_profile_config(cfg).items() if key not in AGENT_RUNTIME_CONFIG_KEYS } - packages = parse_skill_package_ids_json(row.skill_package_ids) + packages = parse_id_list_json(row.skill_package_ids) if packages is None: raw_packages = cfg.get("skill_package_ids") packages = ( diff --git a/src/octop/api/routers/chat/history.py b/src/octop/api/routers/chat/history.py index 7fa3dcfb7d..624656a702 100644 --- a/src/octop/api/routers/chat/history.py +++ b/src/octop/api/routers/chat/history.py @@ -19,11 +19,11 @@ _clamp_history_limit, _load_projected_thread_messages, ) -from octop.infra.agents.context_breakdown import SEGMENT_KEYS, compute_context_breakdown from octop.infra.agents.security.hitl_session import parse_hitl_session_policy -from octop.infra.agents.thread_artifact import thread_artifacts_payload -from octop.infra.agents.thread_fork import fork_dashboard_thread -from octop.infra.agents.workspace_dir import agent_facing_workspace_dir_from_config +from octop.infra.agents.threads.artifact import thread_artifacts_payload +from octop.infra.agents.threads.context_breakdown import SEGMENT_KEYS, compute_context_breakdown +from octop.infra.agents.threads.fork import fork_dashboard_thread +from octop.infra.agents.workspace.dir import agent_facing_workspace_dir_from_config from octop.infra.errors import ErrorCode, OctopError from octop.infra.gateway.hitl.coordinator import pending_hitl_payload from octop.infra.gateway.threads import ThreadRegistry, thread_row_has_messages diff --git a/src/octop/api/routers/chat/routes.py b/src/octop/api/routers/chat/routes.py index 4f2abdc090..2320d61c0d 100644 --- a/src/octop/api/routers/chat/routes.py +++ b/src/octop/api/routers/chat/routes.py @@ -22,7 +22,7 @@ welcome_payload_from_manifest_data, welcome_payload_has_content, ) -from octop.infra.agents.profile import welcome_from_row +from octop.infra.agents.settings.profile import welcome_from_row from octop.infra.agents.teams import is_team_agent from octop.infra.agents.teams.welcome import team_host_welcome_payload from octop.infra.errors import ErrorCode, OctopError diff --git a/src/octop/api/routers/chat/serialize.py b/src/octop/api/routers/chat/serialize.py index 08e95e291c..457cdff4c0 100644 --- a/src/octop/api/routers/chat/serialize.py +++ b/src/octop/api/routers/chat/serialize.py @@ -16,7 +16,7 @@ from octop.api.common.agent_workspace import resolve_agent_workspace_dir from octop.i18n.domains.attachment import attachment_empty_image -from octop.infra.agents.context_breakdown import usage_dict_from_message +from octop.infra.agents.threads.context_breakdown import usage_dict_from_message from octop.infra.gateway.process.message_keys import ( CHECKPOINT_TS_KEY, COMPOSER_CTX_KEY, @@ -525,7 +525,7 @@ async def _backfill_thread_projection( user=user, ) raw = session_rows - from octop.infra.gateway.process.history_projection import message_inputs # noqa: PLC0415 + from octop.infra.history.projection import message_inputs # noqa: PLC0415 projected = await asyncio.to_thread(message_inputs, list(raw)) await asyncio.to_thread(repo.replace_all, thread_id, projected) diff --git a/src/octop/api/routers/experts.py b/src/octop/api/routers/experts.py index 0165913f9c..7e588a284c 100644 --- a/src/octop/api/routers/experts.py +++ b/src/octop/api/routers/experts.py @@ -22,7 +22,7 @@ from octop.api.common.agent_runtime import AgentRuntimeFields, runtime_field_updates from octop.api.common.validators import assert_user_backend_root_dirs from octop.api.deps import current_user, get_server -from octop.infra.agents.avatar import ( +from octop.infra.agents.experts.avatar import ( display_published_expert_icon_url, public_portrait_icon_url, read_snapshot_avatar, diff --git a/src/octop/api/routers/invites.py b/src/octop/api/routers/invites.py index 05101b9af4..d837fe1461 100644 --- a/src/octop/api/routers/invites.py +++ b/src/octop/api/routers/invites.py @@ -142,7 +142,7 @@ async def redeem_invite( email=body.email, locale=locale, ) - from octop.infra.agents.default_agent import try_bootstrap_default_agent + from octop.infra.agents.experts.default_agent import try_bootstrap_default_agent await try_bootstrap_default_agent(server, user_id=user.id, locale=user.locale) secret = server.services.secret_repo.get("jwt") diff --git a/src/octop/api/routers/mbti.py b/src/octop/api/routers/mbti.py index 75933e1bec..94731cc38e 100644 --- a/src/octop/api/routers/mbti.py +++ b/src/octop/api/routers/mbti.py @@ -16,7 +16,7 @@ from octop.api.common.agent import assert_agent_owner from octop.api.deps import current_user, get_server -from octop.infra.agents.mbti_profiles import ( +from octop.infra.agents.persona.mbti_profiles import ( MBTIProfile, get_all_profiles, get_profile, diff --git a/src/octop/api/routers/media_generation.py b/src/octop/api/routers/media_generation.py index 9480ee4eca..5ac85fd92c 100644 --- a/src/octop/api/routers/media_generation.py +++ b/src/octop/api/routers/media_generation.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, Field from octop.api.deps import get_server, require_permission -from octop.infra.agents.media_generation import ( +from octop.infra.agents.settings.media_generation import ( MEDIA_PROVIDER_PRESETS, MediaGenerationSettings, MediaProviderName, diff --git a/src/octop/api/routers/memory_portable.py b/src/octop/api/routers/memory_portable.py index 30d7a281aa..193d59dc56 100644 --- a/src/octop/api/routers/memory_portable.py +++ b/src/octop/api/routers/memory_portable.py @@ -23,7 +23,7 @@ from octop.api.common.content_disposition import content_disposition from octop.api.common.memory_client import memory_db_path_for_cfg, memory_namespace from octop.api.deps import current_user, get_server -from octop.infra.agents.memory_backend import open_memory_kwargs +from octop.infra.agents.memory.backend import open_memory_kwargs from octop.infra.errors import ErrorCode, OctopError logger = logging.getLogger(__name__) diff --git a/src/octop/api/routers/plugins.py b/src/octop/api/routers/plugins.py index 9199d38159..e41ffb90ca 100644 --- a/src/octop/api/routers/plugins.py +++ b/src/octop/api/routers/plugins.py @@ -14,12 +14,12 @@ from octop.api.common.agent import assert_agent_owner as _assert_agent_owner from octop.api.deps import current_user, get_server, require_permission -from octop.infra.agents.plugin_tool_defaults import ( +from octop.infra.agents.plugins.manager import PluginManager +from octop.infra.agents.plugins.plugin_tool_defaults import ( agent_plugin_enabled, merge_plugins_enabled_settings, merge_plugins_tool_settings, ) -from octop.infra.agents.plugins.manager import PluginManager from octop.infra.errors import ErrorCode, OctopError from octop.infra.server import OctopServer diff --git a/src/octop/api/routers/providers.py b/src/octop/api/routers/providers.py index 6cbbf04d86..c9d01285b4 100644 --- a/src/octop/api/routers/providers.py +++ b/src/octop/api/routers/providers.py @@ -12,6 +12,19 @@ from pydantic import BaseModel from octop.api.deps import current_user, get_server, require_permission +from octop.infra.agents.providers.codex_apply import ( + CODEX_PROVIDER_NAME, + apply_codex_credentials, + sync_refreshed_codex_api_key, +) +from octop.infra.agents.providers.codex_oauth import ( + DEVICE_POLL_TIMEOUT_S, + CodexOAuthDeviceCodeError, + exchange_device_code, + get_valid_access_token, + poll_device_token, + request_device_code, +) from octop.infra.agents.providers.model_flags import is_local_runtime_provider from octop.infra.agents.providers.presets import load_provider_presets from octop.infra.agents.providers.probe import ( @@ -24,19 +37,6 @@ from octop.infra.agents.providers.resolved import list_resolved_models as _list_resolved_models from octop.infra.agents.providers.store import clear_stale_pins_for_provider from octop.infra.errors import ErrorCode, OctopError -from octop.infra.providers.codex_apply import ( - CODEX_PROVIDER_NAME, - apply_codex_credentials, - sync_refreshed_codex_api_key, -) -from octop.infra.providers.codex_oauth import ( - DEVICE_POLL_TIMEOUT_S, - CodexOAuthDeviceCodeError, - exchange_device_code, - get_valid_access_token, - poll_device_token, - request_device_code, -) from octop.infra.utils.locale import resolve_request_locale from octop.infra.utils.ulid import new_ulid @@ -464,7 +464,7 @@ async def codex_oauth_logout( _: Any = Depends(require_permission("providers")), server: Any = Depends(get_server), ) -> None: - from octop.infra.providers.codex_oauth import delete_codex_token + from octop.infra.agents.providers.codex_oauth import delete_codex_token delete_codex_token(server.services.paths) row = server.services.provider_repo.get_by_name(CODEX_PROVIDER_NAME) diff --git a/src/octop/api/routers/setup.py b/src/octop/api/routers/setup.py index 08eaf59642..e498837caa 100644 --- a/src/octop/api/routers/setup.py +++ b/src/octop/api/routers/setup.py @@ -154,7 +154,7 @@ def _authorize_setup_mid_wizard(authorization: str | None, server: Any) -> str | async def _bootstrap_default_agent(server: Any, *, user_id: int, locale: str = "zh") -> None: """Create the first default agent for a fresh install (pinned id ``main``).""" - from octop.infra.agents.default_agent import ( + from octop.infra.agents.experts.default_agent import ( SETUP_DEFAULT_AGENT_ID, bootstrap_default_agent, user_policy_workspace_root, diff --git a/src/octop/cli/commands/memory.py b/src/octop/cli/commands/memory.py index 247a0e9e55..9a59f9daaa 100644 --- a/src/octop/cli/commands/memory.py +++ b/src/octop/cli/commands/memory.py @@ -11,7 +11,7 @@ from octop.cli.support.ctx import json_output_enabled, resolve_agent from octop.cli.support.db import resolve_cli_locale from octop.i18n import tr -from octop.infra.agents.memory_slim_control import list_memory_slim_agents, request_memory_slim +from octop.infra.agents.memory.slim_control import list_memory_slim_agents, request_memory_slim from octop.infra.utils.paths import PathLayout diff --git a/src/octop/cli/support/offline_ops.py b/src/octop/cli/support/offline_ops.py index fc0acf5443..97fbb241cf 100644 --- a/src/octop/cli/support/offline_ops.py +++ b/src/octop/cli/support/offline_ops.py @@ -247,7 +247,7 @@ def resolve_cron_user_id(agent_id: str, as_user: str | None, *, home: Path | Non def delete_agent_offline(agent_id: str, *, home: Path | None = None) -> None: - from octop.infra.agents.workspace_dir import workspace_dir_from_config_json + from octop.infra.agents.workspace.dir import workspace_dir_from_config_json with open_cli_services(home) as svc: row = svc.agent_repo.get(agent_id) diff --git a/src/octop/infra/__init__.py b/src/octop/infra/__init__.py index c230f7e41b..faa3068af8 100644 --- a/src/octop/infra/__init__.py +++ b/src/octop/infra/__init__.py @@ -1,21 +1,30 @@ -"""Octop infrastructure — all domain logic and utilities. +"""Octop infrastructure — domain logic and utilities. -Sub-packages: - agents — agent factory, runtime, manager, MBTI personas, expert templates - channels — IM channels, processor, slash commands - cron — CronJob, CronManager, trigger parsing - db — SqlitePool, SQL migrations, Repo classes, RepoBundle - users — User/Role/UserToken identity, manager, password +Sub-packages (see ``AGENTS.md`` §5 for ownership boundaries): + agents — AgentManager, providers, settings, workspace, threads, experts, … + auth — captcha + SSO + backend — storage backend resolution / probe + backup — system / workspace / chat backup + browser — local browser environment setup + connectors — connector catalog, OAuth, MCP builder + cron — scheduled jobs + db — pools, migrations, repos, SharedServices + desktop — remote desktop capture / input + gateway — IM / dashboard ingress, threads, slash, media, HITL + history — versioned messages, trajectory, turn projection + knowledge — knowledge bases + mobile — Android remote + proactive — proactive care scheduler + setup — first-run wizard, OS service, TLS, self-update + skills — skill packages + SkillHub HTTP client + users — identity and UserManager + utils — leaf helpers (paths, ulid, locale, …) + voice — STT / TTS Top-level modules: - config — OctopConfig from env vars - errors — project-wide exception hierarchy - server — OctopServer process orchestrator - shared — SharedServices DI container - paths — filesystem layout helpers (~/.octop/...) - ulid — monotonic ULID generator - metrics — process-wide thread-safe in-memory counters - ollama_manager — Ollama local model manager + errors — OctopError / ErrorCode + metrics — in-process counters + server — OctopServer process orchestrator """ from __future__ import annotations diff --git a/src/octop/infra/agents/avatar.py b/src/octop/infra/agents/experts/avatar.py similarity index 100% rename from src/octop/infra/agents/avatar.py rename to src/octop/infra/agents/experts/avatar.py diff --git a/src/octop/infra/agents/experts/catalog.py b/src/octop/infra/agents/experts/catalog.py index fee8095ee2..23d6b4467a 100644 --- a/src/octop/infra/agents/experts/catalog.py +++ b/src/octop/infra/agents/experts/catalog.py @@ -21,7 +21,7 @@ from octop_harness.backends.workspace import BackendWorkspace from octop.infra.agents.manager import AgentCreateSpec -from octop.infra.agents.workspace_dir import DEFAULT_SYSTEM_FILES_PATH +from octop.infra.agents.workspace.dir import DEFAULT_SYSTEM_FILES_PATH from octop.infra.errors import ErrorCode, OctopError logger = logging.getLogger(__name__) diff --git a/src/octop/infra/agents/default_agent.py b/src/octop/infra/agents/experts/default_agent.py similarity index 100% rename from src/octop/infra/agents/default_agent.py rename to src/octop/infra/agents/experts/default_agent.py diff --git a/src/octop/infra/agents/experts/market_creation.py b/src/octop/infra/agents/experts/market_creation.py index 55b8ba747b..fa56037aa0 100644 --- a/src/octop/infra/agents/experts/market_creation.py +++ b/src/octop/infra/agents/experts/market_creation.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from typing import Any, Literal -from octop.infra.agents.avatar import materialize_remote_icon_url +from octop.infra.agents.experts.avatar import materialize_remote_icon_url from octop.infra.agents.experts.catalog import ( WORKSPACE_MANIFEST_PATH, apply_workspace_quick_prompts, diff --git a/src/octop/infra/agents/experts/publish.py b/src/octop/infra/agents/experts/publish.py index 79aa049470..9c74caa2ab 100644 --- a/src/octop/infra/agents/experts/publish.py +++ b/src/octop/infra/agents/experts/publish.py @@ -13,8 +13,8 @@ from octop_harness.backends.workspace import BackendWorkspace -from octop.infra.agents.avatar import copy_workspace_avatar_to_dir from octop.infra.agents.builtin_skills import OCTOP_BUILTIN_SKILLS_ROOT +from octop.infra.agents.experts.avatar import copy_workspace_avatar_to_dir from octop.infra.agents.experts.catalog import ( MANIFEST_FILENAME, read_workspace_manifest_bytes, diff --git a/src/octop/infra/agents/experts/published_creation.py b/src/octop/infra/agents/experts/published_creation.py index 1a4871d2a1..ad964f8b1f 100644 --- a/src/octop/infra/agents/experts/published_creation.py +++ b/src/octop/infra/agents/experts/published_creation.py @@ -12,7 +12,7 @@ from psycopg import IntegrityError as PsycopgIntegrityError -from octop.infra.agents.avatar import ( +from octop.infra.agents.experts.avatar import ( bind_workspace_avatar_icon_url, public_portrait_icon_url, ) diff --git a/src/octop/infra/agents/experts/skillhub_market.py b/src/octop/infra/agents/experts/skillhub_market.py index 8443104081..c34c1778e5 100644 --- a/src/octop/infra/agents/experts/skillhub_market.py +++ b/src/octop/infra/agents/experts/skillhub_market.py @@ -31,6 +31,14 @@ snap_task_examples, ) from octop.infra.skills.install import valid_skillhub_icon_url +from octop.infra.skills.skillhub_common import ( + DEFAULT_SKILLHUB_HOST, + HTTP_READ_CHUNK, + MAX_HTTP_BYTES, + MAX_ZIP_COMPRESSION_RATIO, + MAX_ZIP_ENTRIES, + MAX_ZIP_UNCOMPRESSED_BYTES, +) from octop.infra.utils.ssl_errors import looks_like_ssl_error from octop.infra.utils.utf8_text import ( InvalidSkillManifestEncodingError, @@ -40,18 +48,17 @@ logger = logging.getLogger(__name__) MARKET_EXPERT_PREFIX = "skillhub-skillset-" -DEFAULT_SKILLHUB_HOST = "https://api.skillhub.cn" _HTTP_TIMEOUT = 30 _SKILLSET_PAGE_SIZE = 100 _MAX_SKILLSET_PAGES = 20 _MAX_WORKFLOW_QUICK_PROMPTS = 6 _MAX_TASK_EXAMPLES = 6 _SKILLSET_LIST_CACHE_TTL_SECONDS = 300.0 -_MAX_HTTP_BYTES = 32 * 1024 * 1024 -_MAX_ZIP_ENTRIES = 2_000 -_MAX_ZIP_UNCOMPRESSED_BYTES = 64 * 1024 * 1024 -_MAX_ZIP_COMPRESSION_RATIO = 100.0 -_HTTP_READ_CHUNK = 64 * 1024 +_MAX_HTTP_BYTES = MAX_HTTP_BYTES +_MAX_ZIP_ENTRIES = MAX_ZIP_ENTRIES +_MAX_ZIP_UNCOMPRESSED_BYTES = MAX_ZIP_UNCOMPRESSED_BYTES +_MAX_ZIP_COMPRESSION_RATIO = MAX_ZIP_COMPRESSION_RATIO +_HTTP_READ_CHUNK = HTTP_READ_CHUNK # Product nav order for expert market scene tabs (matches dashboard copy). _SCENE_ORDER = ( "ecommerce", @@ -587,11 +594,6 @@ def _download_skillset_package(slug: str) -> bytes: return _http_get(url, accept="application/zip,*/*") -def _download_skill_package(slug: str) -> bytes: - url = _api_url("/api/v1/download", params={"slug": slug}) - return _http_get(url, accept="application/zip,*/*") - - def _parse_skillset_package( zip_bytes: bytes, *, @@ -694,17 +696,47 @@ def _write_expert_template( def _download_skill_into_template(*, skill_slug: str, expert_dir: Path) -> None: + """Fetch one skill package via the shared ``skills.skillhub_market`` client.""" validate_skillset_slug(skill_slug) - zip_bytes = _download_skill_package(skill_slug) + from octop.infra.skills.skillhub_market import ( # noqa: PLC0415 + SkillHubMarketError as SkillDownloadError, + ) + from octop.infra.skills.skillhub_market import ( + SkillHubMarketTimeout, + SkillHubPackageError, + SkillHubPackageTooLarge, + download_skillhub_package_files, + ) + target_dir = expert_dir / "skills" / skill_slug target_dir.mkdir(parents=True, exist_ok=True) try: - _extract_zip(zip_bytes, target_dir) - except zipfile.BadZipFile as exc: + files = download_skillhub_package_files(skill_slug) + except SkillHubMarketTimeout as exc: + raise SkillHubMarketError( + str(exc), + kind=SkillHubMarketErrorKind.UPSTREAM_TIMEOUT, + ) from exc + except SkillHubPackageTooLarge as exc: + raise SkillHubMarketError( + str(exc), + kind=SkillHubMarketErrorKind.PACKAGE_TOO_LARGE, + ) from exc + except SkillHubPackageError as exc: raise SkillHubMarketError( - f"SkillHub skill package {skill_slug!r} is not a valid zip", + str(exc), kind=SkillHubMarketErrorKind.PACKAGE_INVALID, ) from exc + except SkillDownloadError as exc: + raise SkillHubMarketError( + str(exc), + kind=SkillHubMarketErrorKind.UPSTREAM_FAILED, + ) from exc + + for rel, payload in files: + dest = target_dir / rel + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(payload) def _extract_zip(zip_bytes: bytes, target_dir: Path) -> None: diff --git a/src/octop/infra/agents/manager.py b/src/octop/infra/agents/manager.py index 00b0631417..27f4a9646f 100644 --- a/src/octop/infra/agents/manager.py +++ b/src/octop/infra/agents/manager.py @@ -20,18 +20,25 @@ from octop_harness.security.models import SecurityPolicy from octop.i18n.domains.agents import NO_MODELS_CONFIGURED, format_agent_start_error -from octop.infra.agents.acp_settings import ACPSettingsStore -from octop.infra.agents.langfuse import LangfuseSettings, LangfuseSettingsStore -from octop.infra.agents.media_generation import ( +from octop.infra.agents.memory.backend import memory_backend_from_agent_config +from octop.infra.agents.memory.slim import MemorySlimCoordinator +from octop.infra.agents.providers import ProviderStore, sync_providers_to_harness +from octop.infra.agents.security import SecuritySettingsStore, ToolGuardRulesStore +from octop.infra.agents.security.hitl_session import ( + HitlSessionPolicyStore, + apply_session_bypass, + hitl_thread_scope, + thread_id_from_request, +) +from octop.infra.agents.settings.acp import ACPSettingsStore +from octop.infra.agents.settings.langfuse import LangfuseSettings, LangfuseSettingsStore +from octop.infra.agents.settings.media_generation import ( MediaGenerationSettings, MediaGenerationSettingsStore, MediaProviderUpdate, ) -from octop.infra.agents.memory_backend import memory_backend_from_agent_config -from octop.infra.agents.memory_slim import MemorySlimCoordinator -from octop.infra.agents.profile import ( +from octop.infra.agents.settings.profile import ( dump_id_list, - dump_skill_package_ids, dumps_config, extract_profile_from_config, id_list_from_row, @@ -39,22 +46,14 @@ parse_config_json, strip_profile_config, ) -from octop.infra.agents.providers import ProviderStore, sync_providers_to_harness -from octop.infra.agents.runtime_limits import ( +from octop.infra.agents.settings.runtime_limits import ( AGENT_RUNTIME_CONFIG_KEYS, apply_agent_runtime_to_stream_request, merge_agent_runtime_values, ) -from octop.infra.agents.runtime_limits import ( +from octop.infra.agents.settings.runtime_limits import ( resolve_context_max_tokens as config_context_max_tokens, ) -from octop.infra.agents.security import SecuritySettingsStore, ToolGuardRulesStore -from octop.infra.agents.security.hitl_session import ( - HitlSessionPolicyStore, - apply_session_bypass, - hitl_thread_scope, - thread_id_from_request, -) from octop.infra.backend.docker_spec import ( enrich_docker_backend_spec, inject_docker_global_environment, @@ -119,7 +118,7 @@ def skills_disabled_set(cfg: dict[str, Any]) -> set[str]: def tools_disabled_set(cfg: dict[str, Any]) -> set[str]: """Return disabled built-in tool names from agent config (critical stripped).""" - from octop.infra.agents.tool_catalog import tools_disabled_set as _tools_disabled_set + from octop.infra.agents.settings.tool_catalog import tools_disabled_set as _tools_disabled_set return _tools_disabled_set(cfg) @@ -574,7 +573,7 @@ async def create( ) if spec.persona_mbti: config["persona"] = spec.persona_mbti.upper() - from octop.infra.agents.workspace_dir import ( # noqa: PLC0415 + from octop.infra.agents.workspace.dir import ( # noqa: PLC0415 DEFAULT_SYSTEM_FILES_PATH, seed_workspace_dir_on_create, ) @@ -612,7 +611,7 @@ async def create( profile = extract_profile_from_config(config) config = strip_profile_config(config) package_ids_json = ( - dump_skill_package_ids(spec.skill_package_ids) + dump_id_list(spec.skill_package_ids) if spec.skill_package_ids is not None else profile.get("skill_package_ids") ) @@ -1055,7 +1054,7 @@ def resolve_workspace_dir(self, agent_id: str, *, persist_if_missing: bool = Tru ``_build_harness_config`` parses the persisted string directly from config — do not route harness through this host join. """ - from octop.infra.agents.workspace_dir import ( # noqa: PLC0415 + from octop.infra.agents.workspace.dir import ( # noqa: PLC0415 workspace_dir_from_config, ) @@ -1923,7 +1922,7 @@ async def persist_skills_disabled(self, agent_id: str, disabled: set[str]) -> No async def persist_tools_disabled(self, agent_id: str, disabled: set[str]) -> None: """Persist builtin ``tools_disabled`` and hot-sync the effective denylist.""" - from octop.infra.agents.tool_catalog import normalize_tools_disabled + from octop.infra.agents.settings.tool_catalog import normalize_tools_disabled cfg = self.get_config(agent_id) cleaned = normalize_tools_disabled(sorted(disabled)) @@ -2051,7 +2050,7 @@ async def persist_skill_package_ids(self, agent_id: str, package_ids: list[str]) cfg = self.get_config(agent_id) self._repos.agent_repo.update_config( agent_id, - skill_package_ids=dump_skill_package_ids(normalized_ids), + skill_package_ids=dump_id_list(normalized_ids), config_json=dumps_config(cfg), ) self.sync_skill_package_dirs(agent_id) @@ -2201,7 +2200,7 @@ async def strip_skill_package_id(self, package_id: str) -> None: remaining = [item for item in package_ids if item != package_id] self._repos.agent_repo.update_config( row.agent_id, - skill_package_ids=dump_skill_package_ids(remaining), + skill_package_ids=dump_id_list(remaining), config_json=dumps_config(cfg), ) self.sync_skill_package_dirs(row.agent_id) @@ -2414,8 +2413,8 @@ def sync_effective_tools_disabled(self, agent_id: str) -> None: """Hot-sync builtin + plugin denylist derived from current agent config.""" from octop_harness.plugins import PluginRegistry + from octop.infra.agents.settings.tool_catalog import effective_tools_disabled from octop.infra.agents.teams import host_tools_disabled, is_team_agent - from octop.infra.agents.tool_catalog import effective_tools_disabled cfg = self.get_config(agent_id) global_plugins = ( @@ -2692,7 +2691,7 @@ def _backend_workspace_for_row( from octop_harness.backends import resolve_backend # noqa: PLC0415 from octop_harness.backends.workspace import BackendWorkspace # noqa: PLC0415 - from octop.infra.agents.workspace_dir import system_files_path_from_config # noqa: PLC0415 + from octop.infra.agents.workspace.dir import system_files_path_from_config # noqa: PLC0415 from octop.infra.backend.opensandbox_deps import ensure_opensandbox_deps # noqa: PLC0415 if cfg is None: @@ -2979,7 +2978,7 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: """Convert an AgentRow into a HarnessAgentConfig.""" from octop_harness.middleware.bootstrap import bootstrap_marker_exists # noqa: PLC0415 - from octop.infra.agents.workspace_dir import ( # noqa: PLC0415 + from octop.infra.agents.workspace.dir import ( # noqa: PLC0415 harness_workspace_path, resolve_workspace_host_path, system_files_path_from_config, @@ -3043,7 +3042,7 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: from octop_harness.plugins import PluginRegistry, build_plugin_tools # noqa: PLC0415 - from octop.infra.agents.plugin_tool_defaults import ( # noqa: PLC0415 + from octop.infra.agents.plugins.plugin_tool_defaults import ( # noqa: PLC0415 agent_plugin_enabled, expand_plugin_tools_default_on, ) @@ -3076,7 +3075,7 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: # which strict LLM tool-name APIs reject. Rewrite them to legal names # before binding, keeping the original in the description. Config keys # and the plugin-side closures still use the original names. - from octop.infra.agents.plugin_tool_names import ( # noqa: PLC0415 + from octop.infra.agents.plugins.plugin_tool_names import ( # noqa: PLC0415 extract_original_plugin_label, sanitize_plugin_tool_names, ) @@ -3217,7 +3216,9 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: self._plugin_manager.plugin_skill_names(plugin_id) ) - from octop.infra.agents.execute_env import inject_agent_execute_env # noqa: PLC0415 + from octop.infra.agents.workspace.execute_env import ( + inject_agent_execute_env, # noqa: PLC0415 + ) backend = inject_agent_execute_env( self._prepare_docker_backend(backend, row), @@ -3267,8 +3268,8 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: **_resolve_memory_backend_kwargs(cfg, workspace_dir=workspace_dir, config=self._config), ) if "tools_disabled" in _HARNESS_AGENT_CONFIG_FIELDS: + from octop.infra.agents.settings.tool_catalog import effective_tools_disabled from octop.infra.agents.teams import host_tools_disabled - from octop.infra.agents.tool_catalog import effective_tools_disabled disabled = effective_tools_disabled( cfg, diff --git a/src/octop/infra/agents/memory/__init__.py b/src/octop/infra/agents/memory/__init__.py new file mode 100644 index 0000000000..8c9952b6c0 --- /dev/null +++ b/src/octop/infra/agents/memory/__init__.py @@ -0,0 +1,21 @@ +"""Agent memory backend resolution and host-side slim control.""" + +from octop.infra.agents.memory.backend import ( + memory_backend_from_agent_config, + open_memory_kwargs, +) +from octop.infra.agents.memory.slim import MemorySlimCoordinator +from octop.infra.agents.memory.slim_control import ( + MemorySlimControl, + list_memory_slim_agents, + request_memory_slim, +) + +__all__ = [ + "MemorySlimControl", + "MemorySlimCoordinator", + "list_memory_slim_agents", + "memory_backend_from_agent_config", + "open_memory_kwargs", + "request_memory_slim", +] diff --git a/src/octop/infra/agents/memory_backend.py b/src/octop/infra/agents/memory/backend.py similarity index 98% rename from src/octop/infra/agents/memory_backend.py rename to src/octop/infra/agents/memory/backend.py index b15200a159..e3b62e0097 100644 --- a/src/octop/infra/agents/memory_backend.py +++ b/src/octop/infra/agents/memory/backend.py @@ -6,7 +6,7 @@ from typing import Any from octop.config import OctopConfig -from octop.infra.agents.workspace_dir import host_system_dir +from octop.infra.agents.workspace.dir import host_system_dir from octop.infra.errors import ErrorCode, OctopError diff --git a/src/octop/infra/agents/memory_slim.py b/src/octop/infra/agents/memory/slim.py similarity index 100% rename from src/octop/infra/agents/memory_slim.py rename to src/octop/infra/agents/memory/slim.py diff --git a/src/octop/infra/agents/memory_slim_control.py b/src/octop/infra/agents/memory/slim_control.py similarity index 100% rename from src/octop/infra/agents/memory_slim_control.py rename to src/octop/infra/agents/memory/slim_control.py diff --git a/src/octop/infra/agents/middleware/thread_artifacts.py b/src/octop/infra/agents/middleware/thread_artifacts.py index fb8d5a2d26..e713ce9bde 100644 --- a/src/octop/infra/agents/middleware/thread_artifacts.py +++ b/src/octop/infra/agents/middleware/thread_artifacts.py @@ -13,7 +13,7 @@ from langgraph.prebuilt.tool_node import ToolCallRequest from langgraph.types import Command -from octop.infra.agents.thread_artifact import ( +from octop.infra.agents.threads.artifact import ( extract_artifact_paths, is_artifact_tool_name, ) diff --git a/src/octop/infra/agents/persona/__init__.py b/src/octop/infra/agents/persona/__init__.py new file mode 100644 index 0000000000..164b5e6087 --- /dev/null +++ b/src/octop/infra/agents/persona/__init__.py @@ -0,0 +1,21 @@ +"""Render agent persona system prompt from MBTI profiles or the built-in default template.""" + +from octop.infra.agents.persona.loader import ( + PersonaLoader, + render_persona_template, + resolve_persona_code, +) +from octop.infra.agents.persona.mbti_profiles import ( + MBTIProfile, + get_all_profiles, + get_profile, +) + +__all__ = [ + "MBTIProfile", + "PersonaLoader", + "get_all_profiles", + "get_profile", + "render_persona_template", + "resolve_persona_code", +] diff --git a/src/octop/infra/agents/persona.py b/src/octop/infra/agents/persona/loader.py similarity index 96% rename from src/octop/infra/agents/persona.py rename to src/octop/infra/agents/persona/loader.py index 336bdfd371..278980f0db 100644 --- a/src/octop/infra/agents/persona.py +++ b/src/octop/infra/agents/persona/loader.py @@ -2,7 +2,7 @@ from __future__ import annotations -from octop.infra.agents.mbti_profiles import MBTIProfile, get_profile +from octop.infra.agents.persona.mbti_profiles import MBTIProfile, get_profile _DEFAULT_PERSONA_TEMPLATE = """\ # Persona: Default diff --git a/src/octop/infra/agents/mbti_profiles.py b/src/octop/infra/agents/persona/mbti_profiles.py similarity index 100% rename from src/octop/infra/agents/mbti_profiles.py rename to src/octop/infra/agents/persona/mbti_profiles.py diff --git a/src/octop/infra/agents/plugins/__init__.py b/src/octop/infra/agents/plugins/__init__.py index 70bb6b4cfa..5f990e2a7b 100644 --- a/src/octop/infra/agents/plugins/__init__.py +++ b/src/octop/infra/agents/plugins/__init__.py @@ -1,5 +1,25 @@ """Octop host-side plugin management.""" from octop.infra.agents.plugins.manager import PluginManager +from octop.infra.agents.plugins.plugin_tool_defaults import ( + agent_plugin_enabled, + expand_plugin_tools_default_on, + merge_plugins_enabled_settings, + merge_plugins_tool_settings, +) +from octop.infra.agents.plugins.plugin_tool_names import ( + extract_original_plugin_label, + sanitize_plugin_tool_name, + sanitize_plugin_tool_names, +) -__all__ = ["PluginManager"] +__all__ = [ + "PluginManager", + "agent_plugin_enabled", + "expand_plugin_tools_default_on", + "extract_original_plugin_label", + "merge_plugins_enabled_settings", + "merge_plugins_tool_settings", + "sanitize_plugin_tool_name", + "sanitize_plugin_tool_names", +] diff --git a/src/octop/infra/agents/plugins/manager.py b/src/octop/infra/agents/plugins/manager.py index f4f7e47631..50a5d83cb1 100644 --- a/src/octop/infra/agents/plugins/manager.py +++ b/src/octop/infra/agents/plugins/manager.py @@ -734,7 +734,7 @@ def sync_skills_to_workspace( ) -> None: from octop_harness.backends.workspace import BackendWorkspace - from octop.infra.agents.plugin_tool_defaults import agent_plugin_enabled + from octop.infra.agents.plugins.plugin_tool_defaults import agent_plugin_enabled if not isinstance(workspace, BackendWorkspace): return diff --git a/src/octop/infra/agents/plugin_tool_defaults.py b/src/octop/infra/agents/plugins/plugin_tool_defaults.py similarity index 100% rename from src/octop/infra/agents/plugin_tool_defaults.py rename to src/octop/infra/agents/plugins/plugin_tool_defaults.py diff --git a/src/octop/infra/agents/plugin_tool_names.py b/src/octop/infra/agents/plugins/plugin_tool_names.py similarity index 100% rename from src/octop/infra/agents/plugin_tool_names.py rename to src/octop/infra/agents/plugins/plugin_tool_names.py diff --git a/src/octop/infra/providers/codex_apply.py b/src/octop/infra/agents/providers/codex_apply.py similarity index 97% rename from src/octop/infra/providers/codex_apply.py rename to src/octop/infra/agents/providers/codex_apply.py index 4ca64a4153..0f9cf13ed8 100644 --- a/src/octop/infra/providers/codex_apply.py +++ b/src/octop/infra/agents/providers/codex_apply.py @@ -5,7 +5,7 @@ import json from typing import Any -from octop.infra.providers.codex_oauth import ( +from octop.infra.agents.providers.codex_oauth import ( CODEX_BASE_URL, CodexOAuthCredentials, build_codex_headers, diff --git a/src/octop/infra/providers/codex_oauth.py b/src/octop/infra/agents/providers/codex_oauth.py similarity index 100% rename from src/octop/infra/providers/codex_oauth.py rename to src/octop/infra/agents/providers/codex_oauth.py diff --git a/src/octop/infra/agents/settings/__init__.py b/src/octop/infra/agents/settings/__init__.py new file mode 100644 index 0000000000..abfc1327ce --- /dev/null +++ b/src/octop/infra/agents/settings/__init__.py @@ -0,0 +1,29 @@ +"""Agent-scoped settings: stores, profile columns, runtime knobs, tool catalog.""" + +from octop.infra.agents.settings.acp import ACPSettingsStore +from octop.infra.agents.settings.langfuse import ( + LangfuseSettings, + LangfuseSettingsStore, + verify_langfuse_credentials, +) +from octop.infra.agents.settings.media_generation import ( + MEDIA_PROVIDER_PRESETS, + MediaGenerationSettings, + MediaGenerationSettingsStore, + MediaProviderName, + MediaProviderSettings, + MediaProviderUpdate, +) + +__all__ = [ + "ACPSettingsStore", + "MEDIA_PROVIDER_PRESETS", + "LangfuseSettings", + "LangfuseSettingsStore", + "MediaGenerationSettings", + "MediaGenerationSettingsStore", + "MediaProviderName", + "MediaProviderSettings", + "MediaProviderUpdate", + "verify_langfuse_credentials", +] diff --git a/src/octop/infra/agents/acp_settings.py b/src/octop/infra/agents/settings/acp.py similarity index 100% rename from src/octop/infra/agents/acp_settings.py rename to src/octop/infra/agents/settings/acp.py diff --git a/src/octop/infra/agents/langfuse.py b/src/octop/infra/agents/settings/langfuse.py similarity index 100% rename from src/octop/infra/agents/langfuse.py rename to src/octop/infra/agents/settings/langfuse.py diff --git a/src/octop/infra/agents/media_generation.py b/src/octop/infra/agents/settings/media_generation.py similarity index 100% rename from src/octop/infra/agents/media_generation.py rename to src/octop/infra/agents/settings/media_generation.py diff --git a/src/octop/infra/agents/profile.py b/src/octop/infra/agents/settings/profile.py similarity index 91% rename from src/octop/infra/agents/profile.py rename to src/octop/infra/agents/settings/profile.py index 1a8b4e84f9..43a48b056c 100644 --- a/src/octop/infra/agents/profile.py +++ b/src/octop/infra/agents/settings/profile.py @@ -61,15 +61,6 @@ def dump_id_list(ids: list[str]) -> str: return json.dumps(list(ids), ensure_ascii=False) -def parse_skill_package_ids_json(raw: str | None) -> list[str] | None: - """Parse the ``skill_package_ids`` column. ``None`` means the column is unset.""" - return parse_id_list_json(raw) - - -def dump_skill_package_ids(ids: list[str]) -> str: - return dump_id_list(ids) - - def id_list_from_row(row: Any, attr: str) -> list[str]: """Return a stored JSON id list, or ``[]`` when the column is unset.""" parsed = parse_id_list_json(getattr(row, attr, None)) @@ -140,7 +131,7 @@ def welcome_from_row(row: Any) -> str | None: def overlay_skill_package_ids(cfg: dict[str, Any], row: Any) -> dict[str, Any]: """Prefer the column when set; otherwise keep a legacy ``config_json`` list.""" - from_col = parse_skill_package_ids_json(getattr(row, "skill_package_ids", None)) + from_col = parse_id_list_json(getattr(row, "skill_package_ids", None)) if from_col is None: return cfg merged = dict(cfg) @@ -151,14 +142,12 @@ def overlay_skill_package_ids(cfg: dict[str, Any], row: Any) -> dict[str, Any]: __all__ = [ "PROFILE_CONFIG_KEYS", "dump_id_list", - "dump_skill_package_ids", "dumps_config", "extract_profile_from_config", "id_list_from_row", "overlay_skill_package_ids", "parse_config_json", "parse_id_list_json", - "parse_skill_package_ids_json", "strip_profile_config", "welcome_from_row", ] diff --git a/src/octop/infra/agents/runtime_limits.py b/src/octop/infra/agents/settings/runtime_limits.py similarity index 100% rename from src/octop/infra/agents/runtime_limits.py rename to src/octop/infra/agents/settings/runtime_limits.py diff --git a/src/octop/infra/agents/tool_catalog.py b/src/octop/infra/agents/settings/tool_catalog.py similarity index 100% rename from src/octop/infra/agents/tool_catalog.py rename to src/octop/infra/agents/settings/tool_catalog.py diff --git a/src/octop/infra/agents/teams/service.py b/src/octop/infra/agents/teams/service.py index ec90b71a9f..d772b7fe19 100644 --- a/src/octop/infra/agents/teams/service.py +++ b/src/octop/infra/agents/teams/service.py @@ -7,8 +7,8 @@ from pathlib import Path from typing import Any +from octop.infra.agents.settings.tool_catalog import BUILTIN_TOOL_CATALOG from octop.infra.agents.teams.jobs import TeamJobTracker -from octop.infra.agents.tool_catalog import BUILTIN_TOOL_CATALOG from octop.infra.db.repos.agents import AgentRow from octop.infra.db.services import RepoBundle from octop.infra.errors import ErrorCode, OctopError diff --git a/src/octop/infra/agents/teams/team_manager.py b/src/octop/infra/agents/teams/team_manager.py index 461e9f546e..b89f1cdc50 100644 --- a/src/octop/infra/agents/teams/team_manager.py +++ b/src/octop/infra/agents/teams/team_manager.py @@ -31,9 +31,9 @@ from octop.i18n import tr from octop.infra.agents.teams.service import is_team_agent -from octop.infra.agents.thread_artifact import artifact_path_allowed, extract_artifact_paths +from octop.infra.agents.threads.artifact import artifact_path_allowed, extract_artifact_paths from octop.infra.gateway.media.tool_media import tool_name_base -from octop.infra.gateway.process.history_projection import ( +from octop.infra.history.projection import ( live_message_inputs, message_inputs, ) diff --git a/src/octop/infra/agents/teams/welcome.py b/src/octop/infra/agents/teams/welcome.py index a663c916ac..610e3cbc9f 100644 --- a/src/octop/infra/agents/teams/welcome.py +++ b/src/octop/infra/agents/teams/welcome.py @@ -10,7 +10,7 @@ welcome_payload_from_expert, welcome_payload_from_manifest_data, ) -from octop.infra.agents.profile import welcome_from_row +from octop.infra.agents.settings.profile import welcome_from_row from octop.infra.agents.teams.service import is_team_agent from octop.infra.db.repos.agents import AgentRow diff --git a/src/octop/infra/agents/threads/__init__.py b/src/octop/infra/agents/threads/__init__.py new file mode 100644 index 0000000000..3df8f97145 --- /dev/null +++ b/src/octop/infra/agents/threads/__init__.py @@ -0,0 +1,29 @@ +"""Thread-scoped helpers: artifacts, fork, and context-window breakdown.""" + +from octop.infra.agents.threads.artifact import ( + artifact_path_allowed, + artifact_refs_for_response, + extract_artifact_paths, + is_artifact_tool_name, + normalize_artifact_path, + thread_artifacts_payload, +) +from octop.infra.agents.threads.context_breakdown import ( + SEGMENT_KEYS, + compute_context_breakdown, + usage_dict_from_message, +) +from octop.infra.agents.threads.fork import fork_dashboard_thread + +__all__ = [ + "SEGMENT_KEYS", + "artifact_path_allowed", + "artifact_refs_for_response", + "compute_context_breakdown", + "extract_artifact_paths", + "fork_dashboard_thread", + "is_artifact_tool_name", + "normalize_artifact_path", + "thread_artifacts_payload", + "usage_dict_from_message", +] diff --git a/src/octop/infra/agents/thread_artifact.py b/src/octop/infra/agents/threads/artifact.py similarity index 100% rename from src/octop/infra/agents/thread_artifact.py rename to src/octop/infra/agents/threads/artifact.py diff --git a/src/octop/infra/agents/context_breakdown.py b/src/octop/infra/agents/threads/context_breakdown.py similarity index 100% rename from src/octop/infra/agents/context_breakdown.py rename to src/octop/infra/agents/threads/context_breakdown.py diff --git a/src/octop/infra/agents/thread_fork.py b/src/octop/infra/agents/threads/fork.py similarity index 99% rename from src/octop/infra/agents/thread_fork.py rename to src/octop/infra/agents/threads/fork.py index e13686826c..c8254d154f 100644 --- a/src/octop/infra/agents/thread_fork.py +++ b/src/octop/infra/agents/threads/fork.py @@ -222,7 +222,7 @@ async def read_legacy(anchor: dict[str, Any]) -> list[Any]: prefix = messages[: idx + 1] prefix_projection_inputs: list[Any] | None = None if thread_message_repo is not None: - from octop.infra.gateway.process.history_projection import ( # noqa: PLC0415 + from octop.infra.history.projection import ( # noqa: PLC0415 message_inputs, ) diff --git a/src/octop/infra/agents/workspace/__init__.py b/src/octop/infra/agents/workspace/__init__.py new file mode 100644 index 0000000000..2024c44925 --- /dev/null +++ b/src/octop/infra/agents/workspace/__init__.py @@ -0,0 +1,21 @@ +"""Agent workspace path resolution and execute-env injection.""" + +from octop.infra.agents.workspace.dir import ( + DEFAULT_SYSTEM_FILES_PATH, + agent_facing_workspace_dir_from_config, + host_system_dir, + resolve_workspace_host_path, + skills_discovery_roots, + workspace_dir_from_config_json, +) +from octop.infra.agents.workspace.execute_env import inject_agent_execute_env + +__all__ = [ + "DEFAULT_SYSTEM_FILES_PATH", + "agent_facing_workspace_dir_from_config", + "host_system_dir", + "inject_agent_execute_env", + "resolve_workspace_host_path", + "skills_discovery_roots", + "workspace_dir_from_config_json", +] diff --git a/src/octop/infra/agents/workspace_dir.py b/src/octop/infra/agents/workspace/dir.py similarity index 100% rename from src/octop/infra/agents/workspace_dir.py rename to src/octop/infra/agents/workspace/dir.py diff --git a/src/octop/infra/agents/execute_env.py b/src/octop/infra/agents/workspace/execute_env.py similarity index 98% rename from src/octop/infra/agents/execute_env.py rename to src/octop/infra/agents/workspace/execute_env.py index 4b9d9a155b..fcb8d07bb1 100644 --- a/src/octop/infra/agents/execute_env.py +++ b/src/octop/infra/agents/workspace/execute_env.py @@ -14,7 +14,7 @@ from pathlib import Path from typing import Any -from octop.infra.agents.workspace_dir import ( +from octop.infra.agents.workspace.dir import ( agent_auth_dir, agent_facing_workspace_dir_from_config, agent_facing_workspace_root, diff --git a/src/octop/infra/backup/chats.py b/src/octop/infra/backup/chats.py index 28fa7ff88f..645c3925b3 100644 --- a/src/octop/infra/backup/chats.py +++ b/src/octop/infra/backup/chats.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any -from octop.infra.agents.workspace_dir import DEFAULT_SYSTEM_FILES_PATH +from octop.infra.agents.workspace.dir import DEFAULT_SYSTEM_FILES_PATH from octop.infra.backup.snapshot import _patch_channel_metadata, _rewrite_session_key from octop.infra.db.pool import DatabasePool diff --git a/src/octop/infra/backup/system_archive.py b/src/octop/infra/backup/system_archive.py index 16b1e61f37..41ee773038 100644 --- a/src/octop/infra/backup/system_archive.py +++ b/src/octop/infra/backup/system_archive.py @@ -15,7 +15,7 @@ from octop import __version__ from octop.config import DatabaseConfig -from octop.infra.agents.workspace_dir import ( +from octop.infra.agents.workspace.dir import ( system_files_path_from_config, workspace_dir_from_config_json, ) diff --git a/src/octop/infra/cron/delivery.py b/src/octop/infra/cron/delivery.py index 3eedbd8054..ee46884537 100644 --- a/src/octop/infra/cron/delivery.py +++ b/src/octop/infra/cron/delivery.py @@ -11,10 +11,10 @@ from octop.i18n import tr from octop.infra.cron.task_type import CronTaskType, normalize_cron_task_type from octop.infra.gateway.process import build_harness_request -from octop.infra.gateway.process.history_projection import TurnHistoryTracker, message_inputs from octop.infra.gateway.process.message_keys import COMPOSER_CTX_KEY, build_composer_context from octop.infra.gateway.process.usage_record import UsageTracker, record_turn_usage from octop.infra.gateway.threads import ThreadRegistry +from octop.infra.history.projection import TurnHistoryTracker, message_inputs from octop.infra.knowledge.default_open import stamp_turn_knowledge_config from octop.infra.utils.llm_text import strip_thinking from octop.infra.utils.locale import resolve_user_locale diff --git a/src/octop/infra/db/migrate.py b/src/octop/infra/db/migrate.py index c25cde3005..d1ccf8d190 100644 --- a/src/octop/infra/db/migrate.py +++ b/src/octop/infra/db/migrate.py @@ -210,7 +210,7 @@ def _backfill_agent_profile_from_config(db: DatabasePool) -> None: cols = _table_columns(db, "agents") if "icon_name" not in cols: return - from octop.infra.agents.profile import ( # noqa: PLC0415 + from octop.infra.agents.settings.profile import ( # noqa: PLC0415 PROFILE_CONFIG_KEYS, extract_profile_from_config, parse_config_json, diff --git a/src/octop/infra/gateway/media/inbound_store.py b/src/octop/infra/gateway/media/inbound_store.py index b4441978eb..0ac252b402 100644 --- a/src/octop/infra/gateway/media/inbound_store.py +++ b/src/octop/infra/gateway/media/inbound_store.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING from octop.config import DEFAULT_MAX_UPLOAD_MB, upload_mb_to_bytes -from octop.infra.agents.workspace_dir import ( +from octop.infra.agents.workspace.dir import ( agent_facing_workspace_root, join_agent_facing, ) diff --git a/src/octop/infra/gateway/process/processor.py b/src/octop/infra/gateway/process/processor.py index 211818b274..42db8fe791 100644 --- a/src/octop/infra/gateway/process/processor.py +++ b/src/octop/infra/gateway/process/processor.py @@ -21,8 +21,8 @@ from octop_harness.teams.util import PeerCall, PeerSession from octop.i18n.domains.stream import format_stream_error -from octop.infra.agents.profile import parse_config_json from octop.infra.agents.providers.reasoning import reasoning_request_parameters +from octop.infra.agents.settings.profile import parse_config_json from octop.infra.agents.teams import is_team_agent from octop.infra.agents.teams.team_manager import ( TeamManager, @@ -51,10 +51,6 @@ build_content_from_message, build_harness_request, ) -from octop.infra.gateway.process.history_projection import ( - TurnHistoryTracker, - message_inputs, -) from octop.infra.gateway.process.message_keys import ( resolve_user_id_for_message, sanitize_im_metadata, @@ -70,6 +66,10 @@ 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.history.projection import ( + TurnHistoryTracker, + message_inputs, +) from octop.infra.history.trajectory.settings import agent_trajectory_enabled from octop.infra.knowledge.default_open import stamp_turn_knowledge_config from octop.infra.users.preferences import ( diff --git a/src/octop/infra/gateway/process/stream_project.py b/src/octop/infra/gateway/process/stream_project.py index 70b6400350..a91451306c 100644 --- a/src/octop/infra/gateway/process/stream_project.py +++ b/src/octop/infra/gateway/process/stream_project.py @@ -16,8 +16,8 @@ media_events_from_tool_result, ) from octop.infra.gateway.process.agent_resolve import harness_workspace_for_agent -from octop.infra.gateway.process.history_projection import TurnHistoryTracker from octop.infra.gateway.process.usage_record import UsageTracker +from octop.infra.history.projection import TurnHistoryTracker from octop.infra.utils.locale import DEFAULT_LOCALE, Locale, normalize_locale if TYPE_CHECKING: diff --git a/src/octop/infra/history/legacy.py b/src/octop/infra/history/legacy.py index 12b22771ea..bcad895572 100644 --- a/src/octop/infra/history/legacy.py +++ b/src/octop/infra/history/legacy.py @@ -4,7 +4,7 @@ from typing import Any -from octop.infra.gateway.process.history_projection import _role, message_inputs +from octop.infra.history.projection import _role, message_inputs async def checkpoint_snapshot( diff --git a/src/octop/infra/gateway/process/history_projection.py b/src/octop/infra/history/projection.py similarity index 100% rename from src/octop/infra/gateway/process/history_projection.py rename to src/octop/infra/history/projection.py diff --git a/src/octop/infra/history/recorder.py b/src/octop/infra/history/recorder.py index 00d5e01a6e..2577edeb82 100644 --- a/src/octop/infra/history/recorder.py +++ b/src/octop/infra/history/recorder.py @@ -10,17 +10,17 @@ from langchain_core.messages import AIMessage, ToolMessage -from octop.infra.gateway.process.history_projection import ( - TurnHistoryTracker, - _role, - live_message_input, - message_input, -) from octop.infra.gateway.process.message_keys import ( CHECKPOINT_TS_KEY, STREAM_ERROR_CODE_KEY, STREAM_ERROR_FLAG, ) +from octop.infra.history.projection import ( + TurnHistoryTracker, + _role, + live_message_input, + message_input, +) from octop.infra.history.service import HistoryArchive from octop.infra.history.store import dumps from octop.infra.history.trajectory.projector import _tool_result_fields @@ -119,7 +119,7 @@ def observe(self, chunk: dict[str, Any]) -> None: super().observe(chunk) kind = chunk.get("type") if kind in ("state_snapshot", "state_update"): - from octop.infra.gateway.process.history_projection import ( + from octop.infra.history.projection import ( _chunk_messages, # noqa: PLC0415 ) diff --git a/src/octop/infra/skills/skillhub_common.py b/src/octop/infra/skills/skillhub_common.py new file mode 100644 index 0000000000..4c078f1687 --- /dev/null +++ b/src/octop/infra/skills/skillhub_common.py @@ -0,0 +1,24 @@ +"""Shared SkillHub host and zip/HTTP safety limits. + +Skill search/download lives in :mod:`octop.infra.skills.skillhub_market`. +Expert skillset marketplace lives in +:mod:`octop.infra.agents.experts.skillhub_market` and reuses these limits / host. +""" + +from __future__ import annotations + +DEFAULT_SKILLHUB_HOST = "https://api.skillhub.cn" +MAX_HTTP_BYTES = 32 * 1024 * 1024 +MAX_ZIP_ENTRIES = 2_000 +MAX_ZIP_UNCOMPRESSED_BYTES = 64 * 1024 * 1024 +MAX_ZIP_COMPRESSION_RATIO = 100.0 +HTTP_READ_CHUNK = 64 * 1024 + +__all__ = [ + "DEFAULT_SKILLHUB_HOST", + "HTTP_READ_CHUNK", + "MAX_HTTP_BYTES", + "MAX_ZIP_COMPRESSION_RATIO", + "MAX_ZIP_ENTRIES", + "MAX_ZIP_UNCOMPRESSED_BYTES", +] diff --git a/src/octop/infra/skills/skillhub_market.py b/src/octop/infra/skills/skillhub_market.py index 535fdff7d4..b7bfa809c8 100644 --- a/src/octop/infra/skills/skillhub_market.py +++ b/src/octop/infra/skills/skillhub_market.py @@ -17,6 +17,14 @@ from typing import Any from urllib.parse import urlparse +from octop.infra.skills.skillhub_common import ( + DEFAULT_SKILLHUB_HOST, + HTTP_READ_CHUNK, + MAX_HTTP_BYTES, + MAX_ZIP_COMPRESSION_RATIO, + MAX_ZIP_ENTRIES, + MAX_ZIP_UNCOMPRESSED_BYTES, +) from octop.infra.utils.utf8_text import ( InvalidSkillManifestEncodingError, coerce_utf8_text_bytes, @@ -24,7 +32,6 @@ logger = logging.getLogger(__name__) -DEFAULT_SKILLHUB_HOST = "https://api.skillhub.cn" SEARCH_ENDPOINT = "/api/v1/search" DOWNLOAD_ENDPOINT = "/api/v1/download" RANKING_ENDPOINTS = { @@ -35,11 +42,11 @@ "trending": "/api/v1/showcase/trending", "paid": "/api/v1/showcase/paid", } -_MAX_HTTP_BYTES = 32 * 1024 * 1024 -_MAX_ZIP_ENTRIES = 2_000 -_MAX_ZIP_UNCOMPRESSED_BYTES = 64 * 1024 * 1024 -_MAX_ZIP_COMPRESSION_RATIO = 100 -_HTTP_READ_CHUNK = 64 * 1024 +_MAX_HTTP_BYTES = MAX_HTTP_BYTES +_MAX_ZIP_ENTRIES = MAX_ZIP_ENTRIES +_MAX_ZIP_UNCOMPRESSED_BYTES = MAX_ZIP_UNCOMPRESSED_BYTES +_MAX_ZIP_COMPRESSION_RATIO = MAX_ZIP_COMPRESSION_RATIO +_HTTP_READ_CHUNK = HTTP_READ_CHUNK class SkillHubMarketError(RuntimeError): diff --git a/src/octop/infra/skills/workspace_catalog.py b/src/octop/infra/skills/workspace_catalog.py index 6ef701c187..214010052c 100644 --- a/src/octop/infra/skills/workspace_catalog.py +++ b/src/octop/infra/skills/workspace_catalog.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Any -from octop.infra.agents.workspace_dir import skills_discovery_roots +from octop.infra.agents.workspace.dir import skills_discovery_roots from octop.infra.skills.presentation import apply_skill_presentation from octop.infra.utils.frontmatter import parse_frontmatter from octop.infra.utils.utf8_text import repair_skill_manifest_file diff --git a/src/octop/launch.py b/src/octop/launch.py index 7ab6643b14..efe25ff49b 100644 --- a/src/octop/launch.py +++ b/src/octop/launch.py @@ -144,7 +144,7 @@ async def run_foreground( ) servers.append(uvicorn.Server(single_config)) - from octop.infra.agents.memory_slim_control import MemorySlimControl + from octop.infra.agents.memory.slim_control import MemorySlimControl memory_control = MemorySlimControl(srv) try: diff --git a/tests/integration/test_invites_api.py b/tests/integration/test_invites_api.py index ab368b51f8..4f3cb9102f 100644 --- a/tests/integration/test_invites_api.py +++ b/tests/integration/test_invites_api.py @@ -4,7 +4,7 @@ import json -from octop.infra.agents.default_agent import default_home_local_backend +from octop.infra.agents.experts.default_agent import default_home_local_backend from octop.infra.utils.host_dirs import host_fs_tree_root diff --git a/tests/integration/test_media_generation_api.py b/tests/integration/test_media_generation_api.py index 8985666035..9681a1ca85 100644 --- a/tests/integration/test_media_generation_api.py +++ b/tests/integration/test_media_generation_api.py @@ -60,7 +60,7 @@ async def test_media_generation_model_test_dispatches_provider_configuration( provider = _provider_body(api_key="ark-draft-test") with patch( - "octop.infra.agents.media_generation.MediaGenerationSettingsStore.test_model", + "octop.infra.agents.settings.media_generation.MediaGenerationSettingsStore.test_model", new=AsyncMock(return_value={"ok": True}), ) as test_model: response = await client.post( diff --git a/tests/integration/test_provider_test_draft.py b/tests/integration/test_provider_test_draft.py index ce6701dc4d..6ba05b8e4b 100644 --- a/tests/integration/test_provider_test_draft.py +++ b/tests/integration/test_provider_test_draft.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from urllib.parse import urlparse -from octop.infra.providers.codex_oauth import CodexOAuthDeviceCodeError +from octop.infra.agents.providers.codex_oauth import CodexOAuthDeviceCodeError async def test_admin_test_draft_requires_api_key(env: Any) -> None: diff --git a/tests/live/test_agent_expert_template_live.py b/tests/live/test_agent_expert_template_live.py index 6f8c0e6c32..cff627f4fa 100644 --- a/tests/live/test_agent_expert_template_live.py +++ b/tests/live/test_agent_expert_template_live.py @@ -16,7 +16,7 @@ from octop.infra.agents.experts.catalog import Expert, ExpertCatalog, default_library_root from octop.infra.agents.manager import AgentCreateSpec -from octop.infra.agents.workspace_dir import DEFAULT_SYSTEM_FILES_PATH +from octop.infra.agents.workspace.dir import DEFAULT_SYSTEM_FILES_PATH pytestmark = pytest.mark.live diff --git a/tests/unit/agents/test_acp_settings.py b/tests/unit/agents/test_acp_settings.py index 5ad40c85b4..bd40e0861d 100644 --- a/tests/unit/agents/test_acp_settings.py +++ b/tests/unit/agents/test_acp_settings.py @@ -7,7 +7,7 @@ import pytest -from octop.infra.agents.acp_settings import ACPSettingsStore +from octop.infra.agents.settings.acp import ACPSettingsStore from octop.infra.db.migrate import run_migrations from octop.infra.db.pool import SqlitePool from octop.infra.db.repos.agents import AgentRepo diff --git a/tests/unit/agents/test_agent_avatar.py b/tests/unit/agents/test_agent_avatar.py index ad6321a913..62803d6c68 100644 --- a/tests/unit/agents/test_agent_avatar.py +++ b/tests/unit/agents/test_agent_avatar.py @@ -5,7 +5,7 @@ import pytest from octop_harness.backends.utils import BackendOperationNotSupportedError -from octop.infra.agents.avatar import ( +from octop.infra.agents.experts.avatar import ( MAX_AVATAR_BYTES, agent_avatar_api_path, delete_workspace_avatar, diff --git a/tests/unit/agents/test_agent_manager.py b/tests/unit/agents/test_agent_manager.py index c5224f4285..31e883d516 100644 --- a/tests/unit/agents/test_agent_manager.py +++ b/tests/unit/agents/test_agent_manager.py @@ -38,7 +38,7 @@ async def _collect_async(iterator: AsyncIterator[Any]) -> list[Any]: def _expected_default_backend(manager: AgentManager, agent_id: str) -> dict[str, Any]: - from octop.infra.agents.execute_env import inject_agent_execute_env + from octop.infra.agents.workspace.execute_env import inject_agent_execute_env ws = manager._paths.ensure_agent_workspace(agent_id) return inject_agent_execute_env( diff --git a/tests/unit/agents/test_context_breakdown.py b/tests/unit/agents/test_context_breakdown.py index 57338b4a2d..b4f5bdea2c 100644 --- a/tests/unit/agents/test_context_breakdown.py +++ b/tests/unit/agents/test_context_breakdown.py @@ -7,7 +7,7 @@ import pytest -from octop.infra.agents.context_breakdown import ( +from octop.infra.agents.threads.context_breakdown import ( SEGMENT_KEYS, compute_context_breakdown, ) diff --git a/tests/unit/agents/test_default_agent.py b/tests/unit/agents/test_default_agent.py index c9edce715d..d56c17622f 100644 --- a/tests/unit/agents/test_default_agent.py +++ b/tests/unit/agents/test_default_agent.py @@ -7,13 +7,13 @@ import pytest -from octop.infra.agents.default_agent import ( +from octop.infra.agents.experts.catalog import ExpertCatalog, default_library_root +from octop.infra.agents.experts.default_agent import ( DEFAULT_EXPERT_ID, SETUP_DEFAULT_AGENT_ID, bootstrap_default_agent, default_home_local_backend, ) -from octop.infra.agents.experts.catalog import ExpertCatalog, default_library_root from octop.infra.errors import OctopError from octop.infra.utils.host_dirs import host_fs_tree_root diff --git a/tests/unit/agents/test_execute_env.py b/tests/unit/agents/test_execute_env.py index 3cbf524241..995de7a9eb 100644 --- a/tests/unit/agents/test_execute_env.py +++ b/tests/unit/agents/test_execute_env.py @@ -4,7 +4,7 @@ from pathlib import Path -from octop.infra.agents.execute_env import inject_agent_execute_env +from octop.infra.agents.workspace.execute_env import inject_agent_execute_env from octop.infra.db.repos.agents import AgentRow from octop.infra.utils.env_file import save_env_file from octop.infra.utils.paths import PathLayout diff --git a/tests/unit/agents/test_media_generation_settings.py b/tests/unit/agents/test_media_generation_settings.py index 526e7474db..de83157b75 100644 --- a/tests/unit/agents/test_media_generation_settings.py +++ b/tests/unit/agents/test_media_generation_settings.py @@ -7,7 +7,7 @@ import httpx import pytest -from octop.infra.agents.media_generation import ( +from octop.infra.agents.settings.media_generation import ( DEFAULT_PROVIDER_ID, MediaGenerationSettingsStore, MediaProviderUpdate, diff --git a/tests/unit/agents/test_memory_backend.py b/tests/unit/agents/test_memory_backend.py index 3f29a2fd1d..1ecbd3c629 100644 --- a/tests/unit/agents/test_memory_backend.py +++ b/tests/unit/agents/test_memory_backend.py @@ -5,7 +5,7 @@ import pytest from octop.config import DatabaseConfig, OctopConfig -from octop.infra.agents.memory_backend import memory_backend_from_agent_config, open_memory_kwargs +from octop.infra.agents.memory.backend import memory_backend_from_agent_config, open_memory_kwargs from octop.infra.errors import OctopError diff --git a/tests/unit/agents/test_memory_slim.py b/tests/unit/agents/test_memory_slim.py index f8de9b34db..4b087aae62 100644 --- a/tests/unit/agents/test_memory_slim.py +++ b/tests/unit/agents/test_memory_slim.py @@ -14,8 +14,8 @@ from langgraph.checkpoint.sqlite import SqliteSaver from octop.infra.agents.manager import AgentManager -from octop.infra.agents.memory_slim import MemorySlimCoordinator -from octop.infra.agents.memory_slim_control import MemorySlimControl, request_memory_slim +from octop.infra.agents.memory.slim import MemorySlimCoordinator +from octop.infra.agents.memory.slim_control import MemorySlimControl, request_memory_slim def make_manager(tmp_path): @@ -375,7 +375,7 @@ def get_agent(agent_id): async def test_control_discovery_and_heartbeat_during_unchanged_phase(tmp_path, monkeypatch): from octop_memory.application import checkpoint_maintenance - from octop.infra.agents.memory_slim_control import list_memory_slim_agents + from octop.infra.agents.memory.slim_control import list_memory_slim_agents coordinator, registry, memory, _, _ = make_manager(tmp_path) registry.list_rows = lambda: [SimpleNamespace(agent_id="a", name="助手")] diff --git a/tests/unit/agents/test_persona.py b/tests/unit/agents/test_persona.py index 5913e7b588..53c0e2faeb 100644 --- a/tests/unit/agents/test_persona.py +++ b/tests/unit/agents/test_persona.py @@ -4,8 +4,8 @@ import pytest -from octop.infra.agents.mbti_profiles import get_profile from octop.infra.agents.persona import PersonaLoader, render_persona_template +from octop.infra.agents.persona.mbti_profiles import get_profile @pytest.fixture diff --git a/tests/unit/agents/test_plugin_tool_defaults.py b/tests/unit/agents/test_plugin_tool_defaults.py index 3d682c428d..7f86731933 100644 --- a/tests/unit/agents/test_plugin_tool_defaults.py +++ b/tests/unit/agents/test_plugin_tool_defaults.py @@ -2,7 +2,7 @@ from __future__ import annotations -from octop.infra.agents.plugin_tool_defaults import ( +from octop.infra.agents.plugins.plugin_tool_defaults import ( agent_plugin_enabled, expand_plugin_tools_default_on, merge_plugins_enabled_settings, diff --git a/tests/unit/agents/test_runtime_limits.py b/tests/unit/agents/test_runtime_limits.py index 0b760fc729..144854fae0 100644 --- a/tests/unit/agents/test_runtime_limits.py +++ b/tests/unit/agents/test_runtime_limits.py @@ -2,7 +2,7 @@ from __future__ import annotations -from octop.infra.agents.runtime_limits import ( +from octop.infra.agents.settings.runtime_limits import ( CONFIGURABLE_MAX_INPUT_TOKENS, CONFIGURABLE_MODEL_SETTINGS, agent_max_input_tokens, diff --git a/tests/unit/agents/test_thread_artifacts.py b/tests/unit/agents/test_thread_artifacts.py index da47ee9564..e795ba2409 100644 --- a/tests/unit/agents/test_thread_artifacts.py +++ b/tests/unit/agents/test_thread_artifacts.py @@ -13,7 +13,7 @@ ThreadArtifactsMiddleware, peer_room_thread_id, ) -from octop.infra.agents.thread_artifact import ( +from octop.infra.agents.threads.artifact import ( artifact_path_allowed, artifacts_for_response, extract_artifact_paths, @@ -122,7 +122,7 @@ def test_artifacts_for_response_keeps_producer_path_without_host_join( def test_thread_artifacts_payload_has_legacy_and_refs(tmp_path: Path) -> None: - from octop.infra.agents.thread_artifact import thread_artifacts_payload + from octop.infra.agents.threads.artifact import thread_artifacts_payload ws = _ws(tmp_path) payload = thread_artifacts_payload( diff --git a/tests/unit/agents/test_thread_fork.py b/tests/unit/agents/test_thread_fork.py index 4aad03a4d7..90051ebbfe 100644 --- a/tests/unit/agents/test_thread_fork.py +++ b/tests/unit/agents/test_thread_fork.py @@ -9,7 +9,7 @@ import pytest from langchain_core.messages import AIMessage, HumanMessage, ToolMessage -from octop.infra.agents.thread_fork import ( +from octop.infra.agents.threads.fork import ( _fork_title, find_assistant_fork_index, fork_dashboard_thread, diff --git a/tests/unit/agents/test_tool_catalog.py b/tests/unit/agents/test_tool_catalog.py index 1778178461..e467503f69 100644 --- a/tests/unit/agents/test_tool_catalog.py +++ b/tests/unit/agents/test_tool_catalog.py @@ -2,8 +2,7 @@ from __future__ import annotations -from octop.infra.agents.teams.service import HOST_TOOLS_ALLOWED, HOST_TOOLS_DISABLED -from octop.infra.agents.tool_catalog import ( +from octop.infra.agents.settings.tool_catalog import ( BUILTIN_TOOL_CATALOG, CRITICAL_TOOLS, builtin_tool_available, @@ -12,6 +11,7 @@ plugin_tools_disabled_names, tools_disabled_set, ) +from octop.infra.agents.teams.service import HOST_TOOLS_ALLOWED, HOST_TOOLS_DISABLED def test_team_host_tools_are_dispatch_only() -> None: diff --git a/tests/unit/agents/test_workspace_dir.py b/tests/unit/agents/test_workspace_dir.py index b1a6798be8..a9d305138c 100644 --- a/tests/unit/agents/test_workspace_dir.py +++ b/tests/unit/agents/test_workspace_dir.py @@ -6,7 +6,7 @@ import pytest -from octop.infra.agents.workspace_dir import ( +from octop.infra.agents.workspace.dir import ( agent_facing_workspace_dir_from_config, agent_facing_workspace_root, default_agent_workspace_dir, diff --git a/tests/unit/db/test_agent_profile_columns.py b/tests/unit/db/test_agent_profile_columns.py index f453713901..447d4f6e79 100644 --- a/tests/unit/db/test_agent_profile_columns.py +++ b/tests/unit/db/test_agent_profile_columns.py @@ -5,7 +5,7 @@ import json from pathlib import Path -from octop.infra.agents.profile import ( +from octop.infra.agents.settings.profile import ( extract_profile_from_config, strip_profile_config, welcome_from_row, diff --git a/tests/unit/gateway/test_processor_resource_errors.py b/tests/unit/gateway/test_processor_resource_errors.py index f8bedb4c8f..7e98bd18e0 100644 --- a/tests/unit/gateway/test_processor_resource_errors.py +++ b/tests/unit/gateway/test_processor_resource_errors.py @@ -12,10 +12,10 @@ from octop_gateway.models import ChannelSubject, InboundMessage, TextContent from octop.infra.errors import ErrorCode, OctopError -from octop.infra.gateway.process.history_projection import _wire_text from octop.infra.gateway.process.processor import GlobalProcessor, _stream_error from octop.infra.gateway.slash.dispatcher import SlashDispatcher from octop.infra.gateway.ws import WS_CHANNEL_ID +from octop.infra.history.projection import _wire_text def test_stream_error_preserves_resource_error_code() -> None: diff --git a/tests/unit/gateway/test_versioned_history.py b/tests/unit/gateway/test_versioned_history.py index f1035e7dfd..034ab5c1ce 100644 --- a/tests/unit/gateway/test_versioned_history.py +++ b/tests/unit/gateway/test_versioned_history.py @@ -18,7 +18,7 @@ from octop.infra.db.repos.threads import ThreadRepo from octop.infra.db.repos.trajectory_events import TrajectoryEventRepo from octop.infra.db.repos.users import UserRepo -from octop.infra.gateway.process.history_projection import message_inputs +from octop.infra.history.projection import message_inputs from octop.infra.history.recorder import RecordingTracker from octop.infra.history.service import HistoryArchive from octop.infra.history.store import HistoryStore diff --git a/tests/unit/gateway/test_history_projection.py b/tests/unit/history/test_projection.py similarity index 99% rename from tests/unit/gateway/test_history_projection.py rename to tests/unit/history/test_projection.py index feac4355e1..92db9b69c9 100644 --- a/tests/unit/gateway/test_history_projection.py +++ b/tests/unit/history/test_projection.py @@ -7,17 +7,17 @@ from langchain_core.messages import HumanMessage from octop.infra.gateway.history_backfill import HistoryBackfillQueue -from octop.infra.gateway.process.history_projection import ( - TurnHistoryTracker, - _wire_text, - live_message_input, - message_input, -) from octop.infra.gateway.process.message_keys import ( CHECKPOINT_TS_KEY, STREAM_ERROR_FLAG, parse_checkpoint_ts_ms, ) +from octop.infra.history.projection import ( + TurnHistoryTracker, + _wire_text, + live_message_input, + message_input, +) def test_message_input_preserves_existing_checkpoint_ts() -> None: diff --git a/tests/unit/providers/test_codex_oauth.py b/tests/unit/providers/test_codex_oauth.py index 4738bb3429..cf568a47a1 100644 --- a/tests/unit/providers/test_codex_oauth.py +++ b/tests/unit/providers/test_codex_oauth.py @@ -10,8 +10,8 @@ import pytest -from octop.infra.providers.codex_apply import CODEX_MODELS, CODEX_PROVIDER_NAME -from octop.infra.providers.codex_oauth import ( +from octop.infra.agents.providers.codex_apply import CODEX_MODELS, CODEX_PROVIDER_NAME +from octop.infra.agents.providers.codex_oauth import ( CodexOAuthDeviceCodeError, build_codex_headers, exchange_device_code, diff --git a/tests/unit/test_langfuse_settings.py b/tests/unit/test_langfuse_settings.py index 8bb4496eff..4f77255b79 100644 --- a/tests/unit/test_langfuse_settings.py +++ b/tests/unit/test_langfuse_settings.py @@ -9,7 +9,7 @@ import pytest -from octop.infra.agents.langfuse import LangfuseSettingsStore, verify_langfuse_credentials +from octop.infra.agents.settings.langfuse import LangfuseSettingsStore, verify_langfuse_credentials from octop.infra.db.migrate import run_migrations from octop.infra.db.pool import SqlitePool from octop.infra.db.repos.secrets import SecretRepo diff --git a/tests/unit/test_plugins.py b/tests/unit/test_plugins.py index 1dfba418b8..4415e8905f 100644 --- a/tests/unit/test_plugins.py +++ b/tests/unit/test_plugins.py @@ -14,7 +14,7 @@ load_plugin_dir, ) -from octop.infra.agents.plugin_tool_names import ( +from octop.infra.agents.plugins.plugin_tool_names import ( sanitize_plugin_tool_name, sanitize_plugin_tool_names, ) @@ -52,7 +52,7 @@ def test_build_plugin_tools_respects_enabled_flag() -> None: def test_expand_plugin_tools_default_on_without_agent_config() -> None: """Octop default-on expansion makes tools bind without an agent opt-in.""" - from octop.infra.agents.plugin_tool_defaults import expand_plugin_tools_default_on + from octop.infra.agents.plugins.plugin_tool_defaults import expand_plugin_tools_default_on load_plugin_dir(_FIXTURE, install_deps=False) expanded = expand_plugin_tools_default_on( From bd73bb60aacb846967ce2516b0d730885baad1bc Mon Sep 17 00:00:00 2001 From: HUANG Cheng Date: Fri, 25 Sep 2026 08:06:19 -0700 Subject: [PATCH 019/126] fix(plugins): offload oversized octop_ui payloads to ToolMessage.artifact (#1032) (#1116) Plugin tools returning large octop_ui payloads (e.g. a bangumi season with 1200+ episodes, ~228KB JSON) blew up the LLM context because the same tool-result string served both the model and the UI. Add OctopUiOffloadMiddleware at the innermost agent middleware layer: when a ToolMessage content is a >=4000-char string carrying a non-empty octop_ui renderer and data (and no file:// media refs), the payload is moved in-place to ToolMessage.artifact and the model-visible content is replaced by a compact result that keeps title/summary. Tool id, tool_call_id, name and status are preserved. Frontend restores rendering from artifact: ToolCallData.artifact, closeToolCall extraction, history replay path, and resolvePluginUiData prefers explicit data over data_ref. serialize.py surfaces artifact on tool_result blocks; history recorder backfills artifact as fallback. Spec: docs/octop-ui-payload-offload.md Co-authored-by: jubaoliang --- CHANGELOG.md | 3 + .../pages/Chat/components/MessageBubble.tsx | 13 +- dashboard/src/pages/Chat/hooks/chatStore.ts | 12 ++ dashboard/src/pages/Chat/hooks/sseHelpers.ts | 6 + dashboard/src/pages/Chat/hooks/useChat.ts | 3 + dashboard/src/plugins/toolRenderers/index.ts | 1 + .../toolRenderers/parseToolOutput.test.ts | 65 +++++++ .../plugins/toolRenderers/parseToolOutput.ts | 28 +++ dashboard/src/utils/messageParser.ts | 1 + docs/api.md | 9 + docs/octop-ui-payload-offload.md | 151 +++++++++++++++ src/octop/api/routers/chat/serialize.py | 3 + src/octop/infra/agents/manager.py | 5 + .../agents/middleware/octop_ui_offload.py | 88 +++++++++ src/octop/infra/history/recorder.py | 26 +++ tests/unit/agents/test_octop_ui_offload.py | 183 ++++++++++++++++++ tests/unit/api/test_chat_serialize.py | 39 +++- 17 files changed, 629 insertions(+), 7 deletions(-) create mode 100644 docs/octop-ui-payload-offload.md create mode 100644 src/octop/infra/agents/middleware/octop_ui_offload.py create mode 100644 tests/unit/agents/test_octop_ui_offload.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 87f6012916..8e46a8dba8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ - `wiki_summary` 的 `lang` 不再被拼进请求主机名:此前传 `evil.com#` 会真的向 `https://evil.com` 发请求(`localhost:8443/` 则打本机端口),并把对方返回的摘要回显进聊天;现只接受 `zh` / `en` / `zh-classical` 这类裸子域标签,其余按「语言代码无效」返回错误卡片 - 登录验证码的 `OCTOP_CAPTCHA_V3_MIN_SCORE` 只按 `float()` 解析,未校验取值:填成 `nan` 时 `score < nan` 恒为 `False`,`recaptcha-v3` 的分数门槛被静默关闭(机器分 0.0 也能登录),负数同样放行,`inf`/大于 1 则把所有登录锁死。现按该参数已有的「不可用即回落默认值」规则处理,只接受 `0`–`1` 内的有限数值 +- 企查查在对话中改为按内部 HTTP MCP 加载工具(`mcp_mode=internal`),不再误走进程内 gateway 导致「无法加载 MCP 工具」 +- 定时任务以专家方式执行时,运行失败(工具调用报错、需要人工介入、没有可见回复)也会把任务提示词和已产出的部分内容投影进会话线程;此前这些线程一个字都没有,点「立即执行」后打开对话只看到空会话(#516) +- 插件工具返回超大 octop_ui 数据(如番剧上千集选集列表)时不再把整份 JSON 塞进模型上下文导致爆窗:新增中间件把 ≥4000 字符的 UI payload 原地剥离到 `ToolMessage.artifact`,模型只看到保留 title/summary 的精简结果;前端实时流与历史回放均从 artifact 恢复渲染,不影响媒体提取与文本类工具结果(#1032) ## [1.0.2b2] - 2026-09-23 ### 新增 diff --git a/dashboard/src/pages/Chat/components/MessageBubble.tsx b/dashboard/src/pages/Chat/components/MessageBubble.tsx index 868d7df412..cce6c91a40 100644 --- a/dashboard/src/pages/Chat/components/MessageBubble.tsx +++ b/dashboard/src/pages/Chat/components/MessageBubble.tsx @@ -64,6 +64,7 @@ import { builtinPluginHost, createPluginUiHost, parseOctopToolOutput, + resolvePluginUiData, resolveToolRenderer, useToolRendererVersion, type ToolRenderProps, @@ -379,6 +380,11 @@ export function ToolDetailsInline({ () => parseOctopToolOutput(toolData.output), [toolData.output], ); + // Offloaded octop_ui payloads: explicit data wins; data_ref → artifact. + const resolvedData = useMemo( + () => resolvePluginUiData(parsed, toolData.artifact, toolData.output), + [parsed, toolData.artifact, toolData.output], + ); const pluginId = toolData.pluginId ?? lookupPluginIdForTool(toolData.name) ?? "builtin"; @@ -415,12 +421,7 @@ export function ToolDetailsInline({ callId: toolData.callId, status, args, - data: - parsed.data !== undefined - ? parsed.data - : parsed.isJson - ? parsed.raw - : toolData.output, + data: resolvedData, textFallback: parsed.text, host: registration && registration.pluginId !== "builtin" diff --git a/dashboard/src/pages/Chat/hooks/chatStore.ts b/dashboard/src/pages/Chat/hooks/chatStore.ts index 617bc837fa..919b39d8f1 100644 --- a/dashboard/src/pages/Chat/hooks/chatStore.ts +++ b/dashboard/src/pages/Chat/hooks/chatStore.ts @@ -1756,6 +1756,16 @@ function extractToolResultOutput(messages: unknown[]): string { return ""; } +/** First non-null ToolMessage ``artifact`` in a tool_result frame, if any. */ +function extractToolResultArtifact(messages: unknown[]): unknown { + for (const raw of messages) { + if (!raw || typeof raw !== "object") continue; + const artifact = (raw as Record).artifact; + if (artifact !== undefined && artifact !== null) return artifact; + } + return undefined; +} + function closeToolCall( state: SessionStreamState, messages: unknown[], @@ -1815,6 +1825,7 @@ function closeToolCall( if (toolIdx < 0) return; const target = state.messages[toolIdx]; const output = extractToolResultOutput(messages); + const artifact = extractToolResultArtifact(messages); const explicitToolError = messages.some( (raw) => raw !== null && @@ -1841,6 +1852,7 @@ function closeToolCall( ...(target.toolData ?? {}), output, errorCode, + ...(artifact != null ? { artifact } : {}), }, }, ...state.messages.slice(toolIdx + 1), diff --git a/dashboard/src/pages/Chat/hooks/sseHelpers.ts b/dashboard/src/pages/Chat/hooks/sseHelpers.ts index 3bf9b25881..0cc3f5cba1 100644 --- a/dashboard/src/pages/Chat/hooks/sseHelpers.ts +++ b/dashboard/src/pages/Chat/hooks/sseHelpers.ts @@ -18,6 +18,12 @@ export interface ToolCallData { callId?: string; arguments?: string; output?: string; + /** + * Offloaded ``octop_ui`` payload. When the backend strips a large ``data`` + * field from the tool output (envelope carries ``data_ref: "artifact"``), + * the full payload lands here — never in the model's context. + */ + artifact?: unknown; errorCode?: string; returnCode?: number; /** Owning plugin id when known (from tool index / SSE). */ diff --git a/dashboard/src/pages/Chat/hooks/useChat.ts b/dashboard/src/pages/Chat/hooks/useChat.ts index 66d3448ef3..9ec502a997 100644 --- a/dashboard/src/pages/Chat/hooks/useChat.ts +++ b/dashboard/src/pages/Chat/hooks/useChat.ts @@ -507,6 +507,9 @@ function convertCallEntries(entries: CallEntry[]): ChatMessage[] { output: current.toolData?.output, errorCode: current.toolData?.errorCode, returnCode: current.toolData?.returnCode, + ...(current.toolData?.artifact != null + ? { artifact: current.toolData.artifact } + : {}), }, status: current.status, errorInfo: current.errorInfo, diff --git a/dashboard/src/plugins/toolRenderers/index.ts b/dashboard/src/plugins/toolRenderers/index.ts index d6a5d18a92..6b8206c8cc 100644 --- a/dashboard/src/plugins/toolRenderers/index.ts +++ b/dashboard/src/plugins/toolRenderers/index.ts @@ -14,6 +14,7 @@ export { export { parseOctopToolOutput, mergePatchedToolOutput, + resolvePluginUiData, } from "./parseToolOutput"; export { registerToolRenderer, diff --git a/dashboard/src/plugins/toolRenderers/parseToolOutput.test.ts b/dashboard/src/plugins/toolRenderers/parseToolOutput.test.ts index 4870b59e13..052bb89a5b 100644 --- a/dashboard/src/plugins/toolRenderers/parseToolOutput.test.ts +++ b/dashboard/src/plugins/toolRenderers/parseToolOutput.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { mergePatchedToolOutput, parseOctopToolOutput, + resolvePluginUiData, } from "./parseToolOutput"; import { clearToolRenderers, @@ -52,6 +53,70 @@ describe("mergePatchedToolOutput", () => { }); }); +describe("resolvePluginUiData", () => { + const artifact = { results: [{ season_id: 1 }] }; + + it("resolves data_ref envelopes from the artifact", () => { + const parsed = parseOctopToolOutput( + JSON.stringify({ + octop_ui: { renderer: "bilibili_player", version: 1 }, + data_ref: "artifact", + text: "找到 1 部番剧。", + }), + ); + expect(parsed.data).toBeUndefined(); + expect(resolvePluginUiData(parsed, artifact, undefined)).toBe(artifact); + }); + + it("explicit data wins over data_ref/artifact (patched state)", () => { + const patched = mergePatchedToolOutput( + JSON.stringify({ + octop_ui: { renderer: "bilibili_player" }, + data_ref: "artifact", + }), + { results: [], current_episode: 5 }, + ); + const parsed = parseOctopToolOutput(patched); + const data = resolvePluginUiData(parsed, artifact, undefined) as { + current_episode: number; + }; + expect(data.current_episode).toBe(5); + }); + + it("falls back to legacy shapes without data_ref", () => { + const inline = parseOctopToolOutput( + JSON.stringify({ + octop_ui: { renderer: "demo_card" }, + data: { count: 2 }, + }), + ); + expect(resolvePluginUiData(inline, artifact, undefined)).toEqual({ + count: 2, + }); + + const plainJson = parseOctopToolOutput(JSON.stringify({ foo: 1 })); + expect(resolvePluginUiData(plainJson, artifact, undefined)).toEqual({ + foo: 1, + }); + + const text = parseOctopToolOutput("hello"); + expect(resolvePluginUiData(text, artifact, "hello")).toBe("hello"); + }); + + it("keeps the slim envelope when artifact is missing", () => { + const parsed = parseOctopToolOutput( + JSON.stringify({ + octop_ui: { renderer: "bilibili_player" }, + data_ref: "artifact", + }), + ); + const resolved = resolvePluginUiData(parsed, undefined, undefined) as { + data_ref: string; + }; + expect(resolved.data_ref).toBe("artifact"); + }); +}); + describe("resolveToolRenderer", () => { it("matches by octop_ui.renderer then tool name", () => { clearToolRenderers(); diff --git a/dashboard/src/plugins/toolRenderers/parseToolOutput.ts b/dashboard/src/plugins/toolRenderers/parseToolOutput.ts index 454494e51e..aeaaee9426 100644 --- a/dashboard/src/plugins/toolRenderers/parseToolOutput.ts +++ b/dashboard/src/plugins/toolRenderers/parseToolOutput.ts @@ -82,3 +82,31 @@ export function mergePatchedToolOutput( return String(nextData); } } + +/** + * Resolve the effective UI payload for a tool result. + * + * Precedence: an explicit ``data`` key always wins (e.g. after an L2 + * ``patchResult`` rewrite); an offloaded envelope (``data_ref: "artifact"``) + * falls back to the message ``artifact``; otherwise legacy shapes pass through + * (``raw`` for JSON, ``output`` text otherwise). + */ +export function resolvePluginUiData( + parsed: ParsedToolOutput, + artifact: unknown, + output: string | undefined, +): unknown { + if (parsed.data !== undefined) return parsed.data; + const raw = parsed.raw; + if ( + parsed.octopUi && + raw !== null && + typeof raw === "object" && + !Array.isArray(raw) && + (raw as Record).data_ref === "artifact" && + artifact != null + ) { + return artifact; + } + return parsed.isJson ? parsed.raw : output; +} diff --git a/dashboard/src/utils/messageParser.ts b/dashboard/src/utils/messageParser.ts index 132f61c459..6ffe47d117 100644 --- a/dashboard/src/utils/messageParser.ts +++ b/dashboard/src/utils/messageParser.ts @@ -227,6 +227,7 @@ export function extractToolData( typeof b.returncode === "number" ? (b.returncode as number) : undefined, + ...(b.artifact != null ? { artifact: b.artifact } : {}), }, }; } diff --git a/docs/api.md b/docs/api.md index 1ea86799a4..83d412926d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -168,6 +168,15 @@ because each request is a one-shot continuation. | `DELETE` | `/agents/{id}/chat/sessions/{thread_id}` | owner | `204` (archives the active row) | | `GET` | `/agents/{id}/chat/sessions/{thread_id}/history` | owner | paginated message history; `turn_active` tells a reconnecting client whether to re-`subscribe` over the chat WebSocket | +**Tool result blocks.** `tool_result` blocks in history (and `tool_result` +frames on the chat WebSocket) carry the tool's return value in `output`. When a +plugin returns a large `octop_ui` payload, the backend offloads the envelope's +`data` field: `output` then contains the slim envelope with +`data_ref: "artifact"`, and the full payload is on the block's `artifact` key +(absent otherwise). An explicit `data` key always takes precedence over +`data_ref` when both appear. Clients that render plugin UIs must resolve +`data_ref` from `artifact`; clients that only read `output` keep working. + ### Trajectory ledger | Method | Path | Auth | Notes | diff --git a/docs/octop-ui-payload-offload.md b/docs/octop-ui-payload-offload.md new file mode 100644 index 0000000000..5a6a13c3e2 --- /dev/null +++ b/docs/octop-ui-payload-offload.md @@ -0,0 +1,151 @@ +# octop_ui 大 payload 旁路设计 Spec(artifact 剥离方案) + +> 关联 issue:TencentCloud/Octop #1032(bilibili 插件返回结果过大) +> 状态:v2 · 三方评审已合入(2026-09-23) +> 评审:step-5-preview / mimo-v2.6-pro / MiniMax-M3 三方独立审核,一致 **APPROVE WITH CHANGES**,必改项已全部合入本文(评审记录见 `tmp/spec-review-*.log`)。 +> 本文所有论断均经代码阅读 + 运行时 PoC 双重验证(`tmp/verify_offload_poc.py`,真实 LangGraph agent loop,含原地修改模式复验)。 + +## 1. 问题 + +内置插件 bilibili-anime 的工具 `bilibili_search_anime`(`src/octop/infra/agents/plugins/bundled/bilibili-anime/main.py:138-196`): + +1. 搜索番剧后,对最多 8 个季(`max_seasons` 默认 5、封顶 8,`main.py:168`)逐一调用 `_fetch_episodes()` 拉取**全量剧集**(`main.py:169-174`,每季剧集数无上限); +2. 把 `{octop_ui, data, text}` 整体序列化为一个 JSON 字符串作为 tool result 返回(`main.py:186-196`)。 + +对《凡人修仙传》这类长篇番剧:5 季 × 100+ 集 × 每集约 150-250 字符,tool result 典型 13-24 KB(PoC 复现 22.5 KB / 200 集)。 + +## 2. 根因 + +这个字符串同时服务四类消费者: + +| 消费者 | 需求 | 路径 | +|---|---|---| +| LLM 上下文 | 越小越好 | ToolMessage.content 进 state,下次模型调用全量携带 | +| 历史持久化 | 需要完整数据供回放 | `thread_messages`(recorder 主路径 `message_to_dict`) | +| Dashboard 实时渲染 | 需要完整 data | WS `tool_result` 帧(`model_dump`) | +| 插件 UI(`ui/index.js`) | 需要完整 data | `props.data` ← 解析 tool output | + +模型与 UI 的需求对立。**截断不可行**(UI 丢剧集),必须在「tool 执行后、ToolMessage 进 state 前」把数据面(大 payload)与控制面(摘要 + 渲染提示)分离。 + +## 3. 已验证事实 + +| # | 事实 | 证据 | +|---|---|---| +| F1 | `AgentMiddleware.awrap_tool_call` 可拦截工具结果并替换 ToolMessage(langchain 1.3.18);只写 sync 版会 NotImplementedError | `langchain/agents/middleware/types.py:756-823`;Octop 先例 `infra/agents/middleware/thread_artifacts.py:186-193` | +| F2 | 中间件链组装处可闭包注入 `row`/`ws` | `manager.py:3026-3041`(`ws` 于 `manager.py:2912-2917` 构造) | +| F3 | 模型请求转换(`convert_to_openai_messages`)不含 artifact 字段,artifact 不进模型上下文 | `langchain_core/messages/utils.py:1653-1680`;PoC [3];harness 的 memory/compaction 模块均不读 artifact(grep 实证) | +| F4 | WS 实时帧经 `model_dump()` 序列化,含 artifact;team relay 亦原样透传 | `api/routers/chat/sse.py:9-21`、`ws.py:70-75`、`teams/team_manager.py:754-773`;PoC [5] | +| F5 | 持久化主路径 `message_to_dict` → `messages_from_dict` 往返保留 artifact | `history_projection.py:64-80`、`history.py:291-313`;PoC [4] | +| F6 | **历史 API 序列化丢 artifact**:tool_result block 只输出 content | `api/routers/chat/serialize.py:937-941` | +| F7 | **dashboard 实时路径 content 优先**,且 `ToolCallData` 类型无 artifact 字段、`closeToolCall` 不提取 artifact | `chatStore.ts:1703-1756`、`1759-1858`;`sseHelpers.ts:11-22` | +| F8 | **dashboard 历史路径只读 output 字段** | `dashboard/src/utils/messageParser.ts:210-232` | +| F9 | `patchResult` 是纯前端内存操作,无服务器写回通道,刷新即丢 | `chatStore.ts:1865-1901`;`host.ts:65-68` | +| F10 | recorder 兜底分支(chunk 缺 messages 时)重建 ToolMessage 不含 artifact;常规路径(`_live_wire` → `message_to_dict`)含 artifact。兜底当前几乎不可达(harness 的 tool_result chunk 恒带 messages) | `recorder.py:177-194`;`harness_agent/protocols/langgraph.py:228-234` | +| F11 | trajectory 事件只存 content(`projector.py:85-103`),剥离后 payload 自动变小;clip 仅作用于 summary 字段 | `trajectory/projector.py:285-294`;`tests/unit/trajectory/test_trajectory_list_summarize.py:68` | +| F12 | `PluginContext` 无 workspace/agent 上下文;插件工具是纯函数 | `harness_agent/plugins/context.py:17-115`、`tools.py:78-90` | +| F13 | 插件 UI 的 host 自带鉴权 `request()` | `dashboard/src/plugins/toolRenderers/host.ts:69-71` | +| F14 | workspace 下载路由要求 agent running 且强制 attachment disposition —— 故弃用「workspace 文件 + 下载 URL」方案 | `api/common/workspace.py:37-68`、`api/routers/workspace.py:328-364` | +| F15 | 移动端无 octop_ui 消费(`mobile/tools.py:29` 是 Android UI dump 路径);CLI repl 不读 content | `infra/mobile/tools.py`、`cli/repl/render.py:194` | +| F16 | 插件 UI 用 `{...d, ...next}` 全量回传 patch(纯前端内存) | `bilibili-anime/ui/index.js:42-45`;`parseToolOutput.ts:66-88` | +| F17 | team host 白名单仅 5 个内置工具,插件工具在 host 被 deny;team member 是独立 agent,工具+中间件齐全 | `infra/agents/teams/service.py:32-50`、`manager.py:3233-3235` | +| F18 | 备份导出按原始行拷贝 `thread_messages`,artifact 随 message_json 存活 | `infra/backup/chats.py:81-110` | +| F19 | `thread_fork.py:223-228` 把 messages(含 artifact)灌进新 checkpoint —— 但由 F3,artifact 永不进模型请求;fork 仅复制 checkpoint 存储,**不构成上下文回灌** | `infra/agents/thread_fork.py:223-228` + F3 | +| F20 | 当前 26 个 octop_ui 插件的 `data` 中均不含 `file://`/workspace 媒体路径(grep 零命中) | `src/octop/infra/agents/plugins/bundled/` | + +## 4. 方案 + +### 4.1 新增中间件 `OctopUiOffloadMiddleware` + +位置:`src/octop/infra/agents/middleware/octop_ui_offload.py`。实现 `awrap_tool_call`(async 版,F1): + +1. `result = await handler(request)`;非 ToolMessage(含 Command)→ 原样返回; +2. 剥离条件(全部满足,任一不满足原样返回): + - `result.content` 是 str 且长度 ≥ `_OFFLOAD_MIN_CHARS = 4000`(模块级常量); + - content 可解析为 JSON 对象,含非空 `octop_ui.renderer`; + - 顶层 `data` 键存在且非空(`None`/`{}`/`[]` 不剥离); + - content 不含 `file://`(守护媒体/路径提取消费方,见 §4.6); +3. 剥离动作(**原地修改**,不新建 ToolMessage,保留 `id`/`name`/`tool_call_id`/`status`/`response_metadata`/`additional_kwargs`——PoC [0] 已验证字段存活): + - `result.artifact = data`(`data` 键整体搬家,兄弟字段如 `results[i].episodes_error` 随 data 走,envelope 其余字段原样保留); + - `result.content = 去掉 data 的 envelope + "data_ref": "artifact"`; +4. 任何异常:记 warning + METRICS 计数,原样返回。 + +**挂载位置**:追加到 `agent_middleware` 列表**末尾(最内层)**,在 `manager.py:3026-3041` 组装。理由:langchain 语义「列表首位 = 最外层」,最内层最先看到工具原始返回、剥离后外层所有中间件(harness PII/媒体、Octop 各中间件)观测面一致。对 `ThreadArtifactsMiddleware._paths_from_content` 的影响已由 F20 + `file://` 守护条件双重兜底。 + +**为什么中间件优于改插件 SDK(`response_format="content_and_artifact"`)**:`_to_structured_tool` 目前不透传 response_format(F12),改 SDK 还需每个插件改返回值;中间件对现有 26 个 octop_ui 插件零改动生效,并自动覆盖未来插件。 + +**为什么弃用 workspace 文件 + 下载 URL**:下载路由要求 agent running(F14),agent 停止后历史播放器会挂;artifact 存消息记录内,无 agent 状态依赖,存储体积与现状相同。 + +### 4.2 历史 API 透出 artifact + +`serialize.py:937-941` 的 tool_result block 增加 `artifact` 字段(**仅当** ToolMessage 带非空 artifact 时;additive 变更,不破坏现有消费者)。 + +配套文档:`docs/api.md` 标注 tool_result block 新字段及 `output` 中可能出现 `data_ref: "artifact"` 的语义;CHANGELOG 标注行为变更(见 §5 发布约束)。 + +### 4.3 dashboard 完整实现路径(评审确认的最大缺口,逐点落实) + +1. `ToolCallData` 接口(`dashboard/src/pages/Chat/hooks/sseHelpers.ts:11-22`)增加 `artifact?: unknown`; +2. `closeToolCall`(`chatStore.ts:1759-1858`):从 WS 帧 messages 的 ToolMessage 上提取 artifact 写入 `toolData.artifact`(F4 已证链路不丢字段:`ws.py:70-75` → `chatStore.ts:2236` → `parseHarnessChunk.ts:268-274` 原样透传); +3. 历史路径:`extractToolData`(`messageParser.ts:210-232`)读取 block.artifact(§4.2 提供); +4. 渲染:`MessageBubble.tsx` `ToolDetailsInline`(约 356-432 行)——`parsed.data === undefined` 且 `data_ref === "artifact"` 时,用 `toolData.artifact` 作为 `props.data`; +5. **解析优先级(防 patch 回跳)**:显式 `data` 键恒优先于 `data_ref`→artifact。因为 `mergePatchedToolOutput` 会把 patch 后的 `data` 写回 output 字符串而 `data_ref` 残留,若不定义优先级,用户选集后 UI 会跳回旧状态(`parseToolOutput.ts:66-88`)。 + +### 4.4 recorder 兜底分支补 artifact + +`recorder.py:186-194` 重建 ToolMessage 时透传 artifact。触发概率极低(F10)但后果是历史永久丢数据,属廉价保险;同时加日志。 + +### 4.5 thread_fork 路径:不改代码 + +F19 已论证:artifact 不进模型请求(F3),fork 只复制 checkpoint 存储,无上下文回灌。harness 的 memory/compaction 路径均不读 artifact(F3 证据)。本 spec 不改 fork;集成测试补一条 fork 后用例防回归(§6)。 + +### 4.6 媒体/路径提取消费方守护 + +`ThreadArtifactsMiddleware`(`thread_artifacts.py:314`)与 `tool_media.py` 的富化函数从 content 提取文件路径/媒体块。若未来插件在 octop_ui `data` 内嵌 `file://` 路径,剥离会让这些提取失效。守护 = §4.1 条件中的 `file://` 排除(含则退回旧行为)+ F20 现状为零 + 测试计划加守护用例。 + +### 4.7 明确不做 + +- 不改 harness_agent 插件 SDK,不改任何现有插件代码; +- 不动 `patchResult`(F9 验证无回填通道); +- trajectory 不透 artifact(F11,剥离后该面自动变小); +- 不做 patch 状态的服务端持久化(若未来要做,只存 UI 状态增量,绝不收客户端全量 data 覆盖存储); +- bilibili 工具的模型侧分页参数(`season_id`/`page`)是后续可选增强,不在本 spec; +- 不为超大 artifact(如 >100KB)引入 workspace 文件二级通道——后续需要时再议。 + +## 5. 风险与发布约束 + +| 风险 | 评估 | 缓解 | +|---|---|---| +| **前后端必须同批发布** | 后端先上、前端未更新 → 剥离后的 envelope 让旧前端渲染空。wheel 内嵌 dashboard 构建产物,天然原子 ✅ | 自托管分离部署场景在 CHANGELOG 标注;第三方 API 消费者迁移指引写进 `docs/api.md` | +| 新旧消息混存 | 老消息无 `data_ref`/artifact,新消息有 | 前端两条解析路径都要兼容(§4.3.4-4.3.5),测试覆盖 | +| WS 帧带宽 | artifact 随 `model_dump` 进帧,体积与今天相同(今天全量走 content) | 无回归 | +| DB 行体积 | 不变(content → artifact 同体积搬家) | — | +| 模型失去剧集明细可见性 | content 只留 `text` 摘要(数量+默认季标题),追问「第 5 集叫什么」答不出 | 行为变更写入 CHANGELOG;分页查询工具为后续增强 | +| ACP 客户端 | `harness_agent/acp/server.py:124-136` 只读 content → IDE 端看到 slim envelope | 可接受(本来也渲染不了 UI),文档注明 | +| context_usage 估算 | `harness_agent/context_usage.py` 按 content 估 token,剥离后估算与模型实际一致 | 属改善 | +| IM 通道 | 只发 tool_start/tool_end 提示行 + 媒体事件,不读 envelope | 无变化(F15、`stream_project.py:166-207`) | +| team 模式 | host 无插件工具可拦(F17);member 正常生效,relay 透传 artifact(F4) | 关闭此项 | + +## 6. 测试计划 + +后端: +- 中间件单元测试(新建 `tests/unit/agents/middleware/test_octop_ui_offload.py`):剥离 / 小 payload 不动 / 非 JSON 不动 / 无 `octop_ui` 不动 / `data` 为空不动 / 含 `file://` 不动 / 坏 JSON 不动 / Command 结果不动 / 原地修改保留 `id`/`tool_call_id`/`name`/`status` / 异常吞掉并计数; +- `serialize.py` 含 artifact 的序列化用例; +- 集成:agent stream 含 octop_ui 大 payload → WS 帧 content 已压缩且 artifact 在场;历史 API 返回 artifact;fork 后新线程首调不爆量。 + +前端: +- `extractToolResultOutput` / `closeToolCall`:content+artifact 同帧时 artifact 落进 `toolData`; +- `extractToolData` 历史路径读 artifact; +- `parseOctopToolOutput`:`data` 优先于 `data_ref`;老格式(内联 data)兼容; +- `ToolDetailsInline` 在 `data_ref` 时用 artifact 渲染。 + +回归:`make all` + `cd dashboard && npx tsc -b`。 + +机制层已预验证:`tmp/verify_offload_poc.py`(含原地修改模式,全部断言通过)。 + +## 7. 开放问题 → 评审已关闭 + +- Q1 team 模式:**关闭**。host 白名单仅 5 内置工具(F17),插件工具不挂载,无工具可拦;member 正常。 +- Q2 recorder 兜底:**关闭**。常规路径已带 artifact;兜底几乎不可达,§4.4 作廉价保险修复。 +- Q3 阈值 4000:**采纳**。其余 25 个插件正常输出 <2-3KB 不误伤;bilibili 默认参数几乎恒触发(符合预期——它正是要修的对象)。 +- Q4 API 兼容:**关闭**。artifact 字段 additive;`output` 语义变化经 `docs/api.md` + CHANGELOG 处理。 +- Q5 IM 通道:**关闭**。剥离前后 IM 用户所见完全一致。 +- Q6 WS 链路:**关闭**。帧内不丢字段,缺口在前端落点,已落为 §4.3 改动项。 diff --git a/src/octop/api/routers/chat/serialize.py b/src/octop/api/routers/chat/serialize.py index 457cdff4c0..3f33f2df84 100644 --- a/src/octop/api/routers/chat/serialize.py +++ b/src/octop/api/routers/chat/serialize.py @@ -946,6 +946,9 @@ def _serialize_history_message( "name": str(_msg_attr(msg, "name") or ""), "output": output, } + artifact = _msg_attr(msg, "artifact") + if artifact is not None: + result_block["artifact"] = artifact if _msg_attr(msg, "status") == "error": result_block["error_code"] = "tool_error" blocks = [result_block] diff --git a/src/octop/infra/agents/manager.py b/src/octop/infra/agents/manager.py index 27f4a9646f..ad60485fd9 100644 --- a/src/octop/infra/agents/manager.py +++ b/src/octop/infra/agents/manager.py @@ -3101,6 +3101,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.octop_ui_offload import OctopUiOffloadMiddleware 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 @@ -3113,6 +3114,9 @@ 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. + # OctopUiOffload stays innermost so every outer middleware observes the + # slimmed content consistently; it only touches octop_ui plugin envelopes, + # disjoint from the file-tool results ThreadArtifacts cares about. agent_middleware: list[Any] = [ *plugin_middleware, TokenQuotaMiddleware( @@ -3129,6 +3133,7 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: workspace_dir=harness_workspace, agent_id=row.agent_id, ), + OctopUiOffloadMiddleware(), ] merged_tools: list[Any] = [] diff --git a/src/octop/infra/agents/middleware/octop_ui_offload.py b/src/octop/infra/agents/middleware/octop_ui_offload.py new file mode 100644 index 0000000000..01ae6fa785 --- /dev/null +++ b/src/octop/infra/agents/middleware/octop_ui_offload.py @@ -0,0 +1,88 @@ +"""Offload oversized ``octop_ui`` tool payloads onto ``ToolMessage.artifact``. + +Plugin tools return one JSON envelope string (``octop_ui`` hint + ``data`` + +``text``) that serves both the model and the dashboard. For chatty tools like +bilibili-anime the ``data`` payload can reach tens of KB, which then rides the +model context on every subsequent turn. + +Model-request converters only serialize ``message.content`` — ``artifact`` is +kept on the message (history, WS frames) but never sent to the model. So for +large envelopes we move ``data`` to ``artifact`` here, leaving the model a slim +envelope with ``data_ref: "artifact"``. The dashboard resolves the reference +when rendering. Design: ``docs/octop-ui-payload-offload.md``. +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Awaitable, Callable +from typing import Any + +from langchain.agents.middleware import AgentMiddleware +from langchain_core.messages import ToolMessage +from langgraph.prebuilt.tool_node import ToolCallRequest +from langgraph.types import Command + +logger = logging.getLogger(__name__) + +_OFFLOAD_MIN_CHARS = 4000 +# Tool results embedding workspace media references must stay intact: media +# extraction (tool_media) and thread-artifact path scans read ``content``. +_FILE_URL_GUARD = "file://" + + +class OctopUiOffloadMiddleware(AgentMiddleware[Any, Any]): + """Move oversized ``octop_ui`` ``data`` payloads to ``ToolMessage.artifact``.""" + + def wrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], ToolMessage | Command[Any]], + ) -> ToolMessage | Command[Any]: + result = handler(request) + self._try_offload(result) + return result + + async def awrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]], + ) -> ToolMessage | Command[Any]: + result = await handler(request) + self._try_offload(result) + return result + + def _try_offload(self, result: ToolMessage | Command[Any]) -> None: + try: + self._offload(result) + except Exception: + # Offload must never break a tool call. + logger.warning("octop_ui offload failed", exc_info=True) + + def _offload(self, result: ToolMessage | Command[Any]) -> None: + if not isinstance(result, ToolMessage): + return + content = result.content + if not isinstance(content, str) or len(content) < _OFFLOAD_MIN_CHARS: + return + if '"octop_ui"' not in content or _FILE_URL_GUARD in content: + return + try: + envelope = json.loads(content) + except (ValueError, TypeError): + return + if not isinstance(envelope, dict): + return + hint = envelope.get("octop_ui") + if not isinstance(hint, dict) or not str(hint.get("renderer") or "").strip(): + return + data = envelope.pop("data", None) + if not data: + return + envelope["data_ref"] = "artifact" + result.content = json.dumps(envelope, ensure_ascii=False) + result.artifact = data + + +__all__ = ["OctopUiOffloadMiddleware"] diff --git a/src/octop/infra/history/recorder.py b/src/octop/infra/history/recorder.py index 2577edeb82..d36f9c72a0 100644 --- a/src/octop/infra/history/recorder.py +++ b/src/octop/infra/history/recorder.py @@ -5,6 +5,7 @@ import asyncio import hashlib import json +import logging import re from typing import Any, cast @@ -25,6 +26,8 @@ from octop.infra.history.store import dumps from octop.infra.history.trajectory.projector import _tool_result_fields +logger = logging.getLogger(__name__) + def _live_wire(message: Any) -> dict[str, Any] | None: item = live_message_input(message) @@ -33,6 +36,23 @@ def _live_wire(message: Any) -> dict[str, Any] | None: return cast(dict[str, Any], json.loads(item.message_json)) +def _chunk_artifact(chunk: dict[str, Any]) -> Any: + """Best-effort artifact extraction for the no-messages fallback path.""" + artifact = chunk.get("artifact") + if artifact is not None: + return artifact + messages = chunk.get("messages") + if isinstance(messages, list): + for message in messages: + if isinstance(message, dict): + value = message.get("artifact") + else: + value = getattr(message, "artifact", None) + if value is not None: + return value + return None + + class RecordingTracker(TurnHistoryTracker): def __init__(self, archive: HistoryArchive, turn: dict[str, Any], seeds: list[Any]) -> None: super().__init__(seed_messages=seeds) @@ -184,12 +204,18 @@ def observe(self, chunk: dict[str, Any]) -> None: wires.append(wire) if not wires: call_id, name, result = _tool_result_fields(chunk) + logger.info( + "tool_result fallback used for %s (%s): no wire-able messages", + name, + call_id, + ) fallback = _live_wire( ToolMessage( content=result if isinstance(result, (str, list)) else dumps(result), name=name, tool_call_id=call_id, id=f"{self.turn['id']}:tool:{call_id}", + artifact=_chunk_artifact(chunk), ) ) wires = [fallback] if fallback is not None else [] diff --git a/tests/unit/agents/test_octop_ui_offload.py b/tests/unit/agents/test_octop_ui_offload.py new file mode 100644 index 0000000000..fa424f7513 --- /dev/null +++ b/tests/unit/agents/test_octop_ui_offload.py @@ -0,0 +1,183 @@ +"""OctopUiOffloadMiddleware: oversized octop_ui payloads move to ToolMessage.artifact.""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from langchain_core.messages import ToolMessage +from langgraph.types import Command + +from octop.infra.agents.middleware.octop_ui_offload import ( + _OFFLOAD_MIN_CHARS, + OctopUiOffloadMiddleware, +) + + +def _request() -> MagicMock: + req = MagicMock() + req.tool_call = {"name": "bilibili_search_anime", "args": {}, "id": "tc1"} + return req + + +def _envelope(data: Any = None) -> dict[str, Any]: + return { + "octop_ui": {"renderer": "bilibili_player", "version": 1}, + "data": data, + "text": "找到 1 部番剧。", + } + + +def _big_data() -> dict[str, Any]: + return { + "results": [ + { + "season_id": 1, + "title": "凡人修仙传", + "episodes": [ + { + "index": i, + "bvid": f"BV1xx4y1z{i:04d}", + "long_title": f"第{i}话 标题", + } + for i in range(1, 201) + ], + } + ] + } + + +def _big_message() -> ToolMessage: + content = json.dumps(_envelope(_big_data()), ensure_ascii=False) + assert len(content) >= _OFFLOAD_MIN_CHARS + return ToolMessage( + content=content, + tool_call_id="tc1", + name="bilibili_search_anime", + id="msg-1", + ) + + +def test_offloads_oversized_octop_ui_payload_in_place() -> None: + mw = OctopUiOffloadMiddleware() + result = _big_message() + out = mw.wrap_tool_call(_request(), lambda _req: result) + + assert out is result + # Field preservation (id / tool_call_id / name / status). + assert out.id == "msg-1" + assert out.tool_call_id == "tc1" + assert out.name == "bilibili_search_anime" + assert out.status == "success" + + envelope = json.loads(out.content) + assert "data" not in envelope + assert envelope["data_ref"] == "artifact" + assert envelope["octop_ui"]["renderer"] == "bilibili_player" + assert envelope["text"] == "找到 1 部番剧。" + assert len(out.content) < _OFFLOAD_MIN_CHARS + + artifact = out.artifact + assert artifact["results"][0]["title"] == "凡人修仙传" + assert len(artifact["results"][0]["episodes"]) == 200 + + +@pytest.mark.asyncio +async def test_awrap_tool_call_offloads() -> None: + mw = OctopUiOffloadMiddleware() + result = _big_message() + + async def handler(_req: Any) -> ToolMessage: + return result + + out = await mw.awrap_tool_call(_request(), handler) + assert json.loads(out.content)["data_ref"] == "artifact" + assert out.artifact["results"] + + +def test_skips_small_payload() -> None: + mw = OctopUiOffloadMiddleware() + content = json.dumps(_envelope({"results": []}), ensure_ascii=False) + assert len(content) < _OFFLOAD_MIN_CHARS + result = ToolMessage(content=content, tool_call_id="tc1") + out = mw.wrap_tool_call(_request(), lambda _req: result) + assert out.content == content + assert out.artifact is None + + +def test_skips_non_json_text() -> None: + mw = OctopUiOffloadMiddleware() + content = "x" * (_OFFLOAD_MIN_CHARS + 10) + result = ToolMessage(content=content, tool_call_id="tc1") + out = mw.wrap_tool_call(_request(), lambda _req: result) + assert out.content == content + assert out.artifact is None + + +def test_skips_json_without_octop_ui() -> None: + mw = OctopUiOffloadMiddleware() + content = json.dumps({"data": {"big": "x" * _OFFLOAD_MIN_CHARS}}) + result = ToolMessage(content=content, tool_call_id="tc1") + out = mw.wrap_tool_call(_request(), lambda _req: result) + assert out.content == content + assert out.artifact is None + + +def test_skips_octop_ui_without_renderer() -> None: + mw = OctopUiOffloadMiddleware() + envelope = _envelope({"big": "x" * _OFFLOAD_MIN_CHARS}) + envelope["octop_ui"] = {"renderer": " "} + content = json.dumps(envelope) + result = ToolMessage(content=content, tool_call_id="tc1") + out = mw.wrap_tool_call(_request(), lambda _req: result) + assert out.content == content + assert out.artifact is None + + +def test_skips_empty_data() -> None: + mw = OctopUiOffloadMiddleware() + for empty in (None, {}, []): + envelope = _envelope(empty) + envelope["pad"] = "x" * _OFFLOAD_MIN_CHARS + content = json.dumps(envelope, ensure_ascii=False) + result = ToolMessage(content=content, tool_call_id="tc1") + out = mw.wrap_tool_call(_request(), lambda _req, r=result: r) + assert out.content == content + assert out.artifact is None + + +def test_skips_payload_with_file_url() -> None: + mw = OctopUiOffloadMiddleware() + data = _big_data() + data["results"][0]["cover_file"] = "file:///tmp/cover.png" + content = json.dumps(_envelope(data), ensure_ascii=False) + result = ToolMessage(content=content, tool_call_id="tc1") + out = mw.wrap_tool_call(_request(), lambda _req: result) + assert out.content == content + assert out.artifact is None + + +def test_skips_non_string_content_and_command() -> None: + mw = OctopUiOffloadMiddleware() + blocks = [{"type": "text", "text": "x" * (_OFFLOAD_MIN_CHARS + 10)}] + result = ToolMessage(content=blocks, tool_call_id="tc1") + out = mw.wrap_tool_call(_request(), lambda _req: result) + assert out.content == blocks + assert out.artifact is None + + cmd = Command() + assert mw.wrap_tool_call(_request(), lambda _req: cmd) is cmd + + +def test_offload_never_breaks_tool_call() -> None: + mw = OctopUiOffloadMiddleware() + result = _big_message() + with patch( + "octop.infra.agents.middleware.octop_ui_offload.json.loads", + side_effect=RuntimeError("boom"), + ): + out = mw.wrap_tool_call(_request(), lambda _req: result) + assert out is result + assert out.artifact is None diff --git a/tests/unit/api/test_chat_serialize.py b/tests/unit/api/test_chat_serialize.py index 0797aba694..75a55c7630 100644 --- a/tests/unit/api/test_chat_serialize.py +++ b/tests/unit/api/test_chat_serialize.py @@ -4,8 +4,9 @@ import json -from langchain_core.messages import AIMessage, HumanMessage, SystemMessage +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage +from octop.api.routers.chat.serialize import _serialize_history_message from octop.api.routers.chat.sse import json_chunk_default @@ -26,3 +27,39 @@ def test_json_chunk_default_serializes_langchain_messages() -> None: assert parsed["type"] == "state_update" assert len(parsed["data"]["messages"]) == 3 assert parsed["data"]["messages"][0]["content"] == "bootstrap" + + +def test_json_chunk_default_keeps_tool_message_artifact() -> None: + """Live WS/SSE frames must carry the offloaded octop_ui payload.""" + msg = ToolMessage( + content='{"octop_ui": {"renderer": "bilibili_player"}, "data_ref": "artifact"}', + tool_call_id="call_1", + name="bilibili_search_anime", + artifact={"results": [{"season_id": 1, "episodes": [{"index": 1}]}]}, + ) + payload = json.dumps({"type": "tool_result", "messages": [msg]}, default=json_chunk_default) + parsed = json.loads(payload) + wire_msg = parsed["messages"][0] + assert wire_msg["artifact"]["results"][0]["season_id"] == 1 + assert "episodes" not in wire_msg["content"] + + +def test_serialize_history_message_includes_tool_artifact() -> None: + msg = ToolMessage( + content='{"octop_ui": {"renderer": "bilibili_player"}, "data_ref": "artifact"}', + tool_call_id="call_1", + name="bilibili_search_anime", + artifact={"results": [{"season_id": 1}]}, + ) + entry = _serialize_history_message(msg) + assert entry is not None + block = entry["content"][0] + assert block["type"] == "tool_result" + assert block["artifact"]["results"][0]["season_id"] == 1 + + +def test_serialize_history_message_omits_artifact_key_when_absent() -> None: + msg = ToolMessage(content="found", tool_call_id="call_1", name="search") + entry = _serialize_history_message(msg) + assert entry is not None + assert "artifact" not in entry["content"][0] From 4c3bf76479cf53f89c5df9c6b8c61922f74ea9dc Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Fri, 25 Sep 2026 15:11:51 +0000 Subject: [PATCH 020/126] fix: team chat/IM UX, ACP sandbox, channel thinking, and related polish Improve team streaming visibility (live speakers, generating footer, IM dispatch/wrap-up), relax ACP tool enable under sandbox while locking runners, strip channel think tags per show_thinking, and clarify provider CLI usage in the assistant skill. Includes self-update/UpdateConfig WIP from the same working tree. Co-authored-by: Cursor --- dashboard/src/components/BrowserAiPanel.tsx | 17 +- dashboard/src/components/MobileAiPanel.tsx | 15 +- dashboard/src/locales/en.json | 18 +- dashboard/src/locales/zh.json | 18 +- .../pages/Agent/ACP/components/ACPCard.tsx | 4 +- dashboard/src/pages/Agent/ACP/index.tsx | 41 +-- .../src/pages/Chat/chatMessages.partial.less | 110 +++++++ .../AssistantProcessSummary.test.tsx | 47 +++ .../components/AssistantProcessSummary.tsx | 82 +++++- .../Chat/components/AssistantTurnView.tsx | 56 +++- .../src/pages/Chat/components/ChatInput.tsx | 1 + .../Chat/components/ChatInputActionsRow.tsx | 24 +- .../Chat/components/GeneratingIndicator.tsx | 66 +++-- .../src/pages/Chat/components/MessageList.tsx | 46 ++- .../Chat/components/ScrollToBottomButton.tsx | 13 +- .../Chat/components/generatingGate.test.ts | 41 ++- .../pages/Chat/components/generatingGate.ts | 27 +- .../Chat/components/liveAssistantTurn.test.ts | 6 +- .../Chat/components/liveAssistantTurn.ts | 26 +- .../Chat/hooks/chatStore.teamStream.test.ts | 47 +++ dashboard/src/pages/Chat/hooks/chatStore.ts | 44 +++ dashboard/src/pages/Chat/hooks/sseHelpers.ts | 8 + dashboard/src/pages/Chat/hooks/useChat.ts | 2 + dashboard/src/pages/Chat/index.tsx | 5 +- .../src/pages/Chat/utils/chatAttachments.ts | 10 +- .../pages/Chat/utils/liveSpeakerLabel.test.ts | 84 ++++++ .../src/pages/Chat/utils/liveSpeakerLabel.ts | 73 +++++ .../src/pages/Chat/utils/messageContent.ts | 33 ++- .../pages/Chat/utils/messageGrouping.test.ts | 18 ++ .../Control/Terminal/components/AiPanel.tsx | 26 +- .../agentBackendForm.skillPackages.test.ts | 2 +- .../Experts/components/agentBackendForm.ts | 19 +- .../AdvancedSettings/UpdateConfig.module.less | 25 ++ .../AdvancedSettings/UpdateConfig.tsx | 15 + .../builtin/BuiltinOctopUiFallback.tsx | 16 +- .../builtin/DefaultToolRenderer.tsx | 26 +- desktop/src/build/stamp_version.py | 3 +- docs/expert-teams.md | 2 +- src/octop/api/routers/acp.py | 11 - src/octop/i18n/en.json | 4 +- src/octop/i18n/zh.json | 4 +- .../skills/octop-assistant/SKILL.md | 4 + src/octop/infra/agents/manager.py | 23 +- src/octop/infra/agents/teams/team_manager.py | 47 ++- src/octop/infra/gateway/gateway.py | 56 +++- .../infra/gateway/process/channel_thinking.py | 32 ++ .../infra/gateway/process/response_mode.py | 10 +- .../infra/gateway/process/stream_project.py | 2 + src/octop/infra/setup/self_update.py | 273 ++++++++++++------ src/octop/infra/utils/llm_text.py | 38 +++ tests/integration/test_acp_api.py | 8 +- tests/unit/gateway/test_gateway_push.py | 46 +++ .../gateway/test_global_processor_team.py | 36 ++- tests/unit/gateway/test_response_mode.py | 37 +++ tests/unit/infra/setup/test_self_update.py | 157 ++++++++++ tests/unit/utils/test_llm_text_channel.py | 25 ++ 56 files changed, 1639 insertions(+), 290 deletions(-) create mode 100644 dashboard/src/pages/Chat/utils/liveSpeakerLabel.test.ts create mode 100644 dashboard/src/pages/Chat/utils/liveSpeakerLabel.ts create mode 100644 src/octop/infra/gateway/process/channel_thinking.py create mode 100644 tests/unit/utils/test_llm_text_channel.py diff --git a/dashboard/src/components/BrowserAiPanel.tsx b/dashboard/src/components/BrowserAiPanel.tsx index 9ab87afc3b..d6c18c3e2b 100644 --- a/dashboard/src/components/BrowserAiPanel.tsx +++ b/dashboard/src/components/BrowserAiPanel.tsx @@ -638,7 +638,6 @@ export default function BrowserAiPanel({ messages={messages} isStreaming={isStreaming} sessionKey={threadId ?? undefined} - onCancel={cancelStream} /> )}
@@ -691,11 +690,19 @@ export default function BrowserAiPanel({
diff --git a/dashboard/src/components/MobileAiPanel.tsx b/dashboard/src/components/MobileAiPanel.tsx index 2376d2a037..c9ac6b3588 100644 --- a/dashboard/src/components/MobileAiPanel.tsx +++ b/dashboard/src/components/MobileAiPanel.tsx @@ -1,6 +1,6 @@ import { useCallback, useRef, useState } from "react"; import { Button, Spin, Tooltip } from "antd"; -import { Bot, Send, X } from "lucide-react"; +import { Bot, Send, Square, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { OctopAgent } from "../context/AgentContext"; import AgentSelector from "./AgentSelector"; @@ -183,7 +183,6 @@ export default function MobileAiPanel({ messages={messages} isStreaming={isStreaming} sessionKey={threadId ?? undefined} - onCancel={cancelStream} /> )}
@@ -212,11 +211,15 @@ export default function MobileAiPanel({ diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index f33e563c61..8d761161cd 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -145,7 +145,7 @@ "SKILL_PACKAGE_NOT_FOUND": "Skill package not found.", "SKILL_PACKAGE_NAME_TAKEN": "Skill package {{name}} already exists.", "SKILL_PACKAGE_BACKEND_UNSUPPORTED": "Skill packages currently support only “Full local environment (filesystem + shell)” or “Local filesystem (no shell commands)”, with storage root / (POSIX) or the agent workspace root (Windows default).", - "ACP_BACKEND_UNSUPPORTED": "This agent has a sandbox enabled, so external runners can’t be used. Connecting via IDE / octop acp is unaffected.", + "ACP_BACKEND_UNSUPPORTED": "This agent has a sandbox enabled, so external runners can’t be enabled or edited here. The outbound acp_runner tool toggle is unaffected; connecting via IDE / octop acp is also unaffected.", "PUBLISHED_EXPERT_SLUG_TAKEN": "Published expert slug {{slug}} already exists.", "PUBLISHED_EXPERT_ALREADY_EXISTS": "This agent is already published as “{{name}}”. Refresh the published template instead.", "OIDC_BAD_REQUEST": "Invalid OIDC request: {{detail}}", @@ -1202,6 +1202,14 @@ "continuing": "Continuing", "generating": "Generating", "generatingWithElapsed": "Generating · {{seconds}}s", + "membersResponding": "Members responding", + "memberResponding": "{{name}} is responding", + "membersRespondingNamed": "{{names}} are responding", + "nameJoin": ", ", + "processOrganizing": "Organizing results", + "newRepliesBelow": "New replies", + "teamWrapupBadge": "Host wrap-up", + "stopTeamHint": "Stop this turn; member replies already in flight may still arrive", "scrollToBottom": "Back to bottom", "turnTimeline": { "label": "Conversation turns", @@ -1259,6 +1267,9 @@ "processSummary": "Called {{tools}} tools, {{thinking}} deep thoughts", "processSummaryToolsOnly": "Called {{tools}} tools", "processSummaryThinkingOnly": "{{thinking}} deep thoughts", + "processRunningTool": "Calling {{name}}", + "processRunningTools": "Calling tools", + "processThinkingLive": "Thinking", "send": "Send", "stop": "Stop", "queue": { @@ -3309,6 +3320,7 @@ "stageStarting": "Starting...", "stageDownloading": "Downloading...", "stageInstalling": "Installing...", + "mirrorErrorsTitle": "Per-mirror details", "manualUpgradeHint": "You can also run: octop update --verbose", "restartServiceBtn": "Restart Service", "restarting": "Restarting...", @@ -5362,8 +5374,8 @@ "globalRunnersHint": "Runner configuration below applies to all agents for your account.", "toolEnabledHint": "The acp_runner tool is enabled per agent — switch the agent above to configure the current one.", "toolEnabled": "Enable outbound delegation (acp_runner)", - "outboundBlockedHint": "This agent has a sandbox enabled, so external runners can’t be used. Connecting via IDE / octop acp is unaffected.", - "outboundBlockedTooltip": "Unavailable in sandbox", + "outboundBlockedHint": "This agent has a sandbox enabled, so external runners can’t be enabled or edited here. The outbound acp_runner tool toggle is unaffected; connecting via IDE / octop acp is also unaffected.", + "outboundBlockedTooltip": "Unavailable to enable or edit in sandbox", "runnerUnavailable": "Unavailable", "builtin": "Built-in", "custom": "Custom", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 14fdb0fc67..030ca73c7d 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -145,7 +145,7 @@ "SKILL_PACKAGE_NOT_FOUND": "未找到技能包。", "SKILL_PACKAGE_NAME_TAKEN": "技能包 {{name}} 已存在。", "SKILL_PACKAGE_BACKEND_UNSUPPORTED": "技能包目前仅支持「本地完整环境(文件系统+指令执行)」或「本地文件系统(不能执行指令)」,且存储根目录须为 /(POSIX)或专家的工作区根目录(Windows 默认)。", - "ACP_BACKEND_UNSUPPORTED": "当前专家已启用沙箱环境,无法调用外部 Runner。通过 IDE / octop acp 接入不受影响。", + "ACP_BACKEND_UNSUPPORTED": "当前专家已启用沙箱环境,不可启用或编辑外部 Runner。启用向外委托(acp_runner)不受影响;通过 IDE / octop acp 接入也不受影响。", "PUBLISHED_EXPERT_SLUG_TAKEN": "已存在发布专家 slug:{{slug}}。", "PUBLISHED_EXPERT_ALREADY_EXISTS": "该专家已发布为「{{name}}」,请改为刷新已发布模板。", "OIDC_BAD_REQUEST": "OIDC 请求无效:{{detail}}", @@ -1202,6 +1202,14 @@ "continuing": "继续生成中", "generating": "生成中", "generatingWithElapsed": "生成中 · {{seconds}}s", + "membersResponding": "成员回答中", + "memberResponding": "{{name}} 正在回答", + "membersRespondingNamed": "{{names}} 正在回答", + "nameJoin": "、", + "processOrganizing": "整理结果中", + "newRepliesBelow": "有新回复", + "teamWrapupBadge": "主持人总结", + "stopTeamHint": "停止本轮生成;已开始的成员回复可能仍会继续推送", "scrollToBottom": "回到底部", "turnTimeline": { "label": "对话回合", @@ -1259,6 +1267,9 @@ "processSummary": "已调用 {{tools}} 次工具,{{thinking}} 次深度思考", "processSummaryToolsOnly": "已调用 {{tools}} 次工具", "processSummaryThinkingOnly": "{{thinking}} 次深度思考", + "processRunningTool": "正在调用 {{name}}", + "processRunningTools": "正在调用工具", + "processThinkingLive": "深度思考中", "send": "发送", "stop": "停止", "queue": { @@ -3306,6 +3317,7 @@ "stageStarting": "启动中...", "stageDownloading": "下载中...", "stageInstalling": "安装中...", + "mirrorErrorsTitle": "各镜像详情", "manualUpgradeHint": "也可手动运行:octop update --verbose", "restartServiceBtn": "重启服务", "restarting": "正在重启...", @@ -5501,8 +5513,8 @@ "globalRunnersHint": "以下 Runner 配置对该账号下所有专家生效。", "toolEnabledHint": "acp_runner 工具需按专家单独开启,切换上方专家后为当前专家配置。", "toolEnabled": "启用向外委托(acp_runner)", - "outboundBlockedHint": "当前专家已启用沙箱环境,无法调用外部 Runner。通过 IDE / octop acp 接入不受影响。", - "outboundBlockedTooltip": "沙箱环境下不可用", + "outboundBlockedHint": "当前专家已启用沙箱环境,不可启用或编辑外部 Runner。启用向外委托(acp_runner)不受影响;通过 IDE / octop acp 接入也不受影响。", + "outboundBlockedTooltip": "沙箱环境下不可启用或编辑", "runnerUnavailable": "不可用", "builtin": "内置", "custom": "自定义", diff --git a/dashboard/src/pages/Agent/ACP/components/ACPCard.tsx b/dashboard/src/pages/Agent/ACP/components/ACPCard.tsx index a6e92f8014..4645c270a9 100644 --- a/dashboard/src/pages/Agent/ACP/components/ACPCard.tsx +++ b/dashboard/src/pages/Agent/ACP/components/ACPCard.tsx @@ -61,7 +61,7 @@ export function ACPCard({ .join(" "); const renderStatusBadge = () => { - if (interactionDisabled) { + if (interactionDisabled && !config.enabled) { return ( @@ -88,7 +88,7 @@ export function ACPCard({ return (
diff --git a/dashboard/src/pages/Agent/ACP/index.tsx b/dashboard/src/pages/Agent/ACP/index.tsx index 1d30558a94..916cb8f91b 100644 --- a/dashboard/src/pages/Agent/ACP/index.tsx +++ b/dashboard/src/pages/Agent/ACP/index.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Alert, App, Button, Empty, Form, Switch, Tooltip } from "antd"; +import { Alert, App, Button, Empty, Form, Switch } from "antd"; import { useTranslation } from "react-i18next"; import PageShell from "../../../layouts/PageShell"; @@ -50,6 +50,9 @@ export function ACPPanel() { const outboundBlocked = blocksAcpOutboundFromConfig( activeAgent?.config ?? null, ); + // Under directory sandbox: lock runner enable/edit only. The per-agent + // acp_runner tool toggle stays available (host-spawned runners may already + // be enabled for other agents on this account). useEffect(() => { activeAgentIdRef.current = activeAgentId; @@ -154,7 +157,6 @@ export function ACPPanel() { const handleToolToggle = async (checked: boolean) => { const agentId = activeAgentIdRef.current; if (!agentId || toolLoading) return; - if (checked && outboundBlocked) return; setToolToggleLoading(true); try { await persistToolEnabled(agentId, checked); @@ -191,6 +193,7 @@ export function ACPPanel() { }; const openEdit = (key: string) => { + if (outboundBlocked) return; const cfg = runners[key]; setIsCreateMode(false); setActiveKey(key); @@ -260,25 +263,16 @@ export function ACPPanel() { }); }; - const toolToggleDisabled = - !activeAgentId || toolLoading || (outboundBlocked && !toolEnabled); + const toolToggleDisabled = !activeAgentId || toolLoading; const toolSwitch = ( - - - + ); return ( @@ -331,14 +325,7 @@ export function ACPPanel() { image={Empty.PRESENTED_IMAGE_SIMPLE} /> ) : ( -
+
{t("acp.toolEnabled")} {toolSwitch}
diff --git a/dashboard/src/pages/Chat/chatMessages.partial.less b/dashboard/src/pages/Chat/chatMessages.partial.less index cb99a646a3..e5ee30871d 100644 --- a/dashboard/src/pages/Chat/chatMessages.partial.less +++ b/dashboard/src/pages/Chat/chatMessages.partial.less @@ -119,6 +119,21 @@ animation: scrollBtnEnter 0.24s cubic-bezier(0.34, 1.56, 0.64, 1) both; } +.scrollToBottomBtnHasNew { + color: var(--fn-color-brand); + border-color: color-mix(in srgb, var(--fn-color-brand) 35%, transparent); + background: color-mix( + in srgb, + var(--fn-color-brand) 8%, + var(--fn-bg-elevated, var(--fn-bg-primary)) + ); + + &:hover { + color: var(--fn-color-brand); + border-color: color-mix(in srgb, var(--fn-color-brand) 50%, transparent); + } +} + /* Hidden state — exits downward, pointer-events disabled to avoid ghost clicks */ .scrollToBottomBtnHidden { opacity: 0; @@ -664,6 +679,51 @@ } } +.inlineToolSummaryRunning { + border-color: color-mix(in srgb, var(--fn-color-brand) 28%, transparent); + background: color-mix( + in srgb, + var(--fn-color-brand) 5%, + var(--fn-bg-secondary, #f8fafc) + ); + animation: inlineToolRunningPulse 1.6s ease-in-out infinite; + + .inlineToolIcon { + color: var(--fn-color-brand); + } + + .inlineToolStatus { + color: var(--fn-color-brand); + } +} + +@keyframes inlineToolRunningPulse { + 0%, + 100% { + box-shadow: 0 0 0 0 + color-mix(in srgb, var(--fn-color-brand) 0%, transparent); + } + 50% { + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--fn-color-brand) 12%, transparent); + } +} + +.inlineToolStatusLive { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; + flex-shrink: 0; + color: var(--fn-color-brand); + font-size: 12px; + + .thinkingDot { + width: 5px; + height: 5px; + } +} + .inlineToolIcon { display: inline-flex; align-items: center; @@ -1026,6 +1086,26 @@ min-width: 0; } +.teamWrapupTurn { + margin-top: 6px; + padding-top: 10px; + border-top: 1px dashed var(--fn-border-secondary, rgba(0, 0, 0, 0.08)); +} + +.teamWrapupBadge { + display: inline-flex; + align-items: center; + margin: 0 0 6px; + padding: 1px 8px; + border-radius: 999px; + border: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.08)); + background: var(--fn-bg-secondary, rgba(0, 0, 0, 0.03)); + color: var(--fn-text-tertiary); + font-size: 11px; + line-height: 1.6; + letter-spacing: 0.02em; +} + .knowledgeCitations { display: flex; flex-direction: column; @@ -1182,11 +1262,41 @@ } } +.processSummaryToggleLive { + color: var(--fn-text-secondary); + + &:hover { + color: var(--fn-text-primary); + } +} + .processSummaryText { flex: 1; min-width: 0; } +.processLiveHint { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + color: var(--fn-color-brand); + + .thinkingDot { + width: 5px; + height: 5px; + flex-shrink: 0; + } +} + +.processLiveLabel { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + animation: thinkingFadeInOut 2s ease-in-out infinite; +} + .processSummaryChevron { flex-shrink: 0; color: var(--fn-text-quaternary); diff --git a/dashboard/src/pages/Chat/components/AssistantProcessSummary.test.tsx b/dashboard/src/pages/Chat/components/AssistantProcessSummary.test.tsx index f89171e07e..44e579a616 100644 --- a/dashboard/src/pages/Chat/components/AssistantProcessSummary.test.tsx +++ b/dashboard/src/pages/Chat/components/AssistantProcessSummary.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AssistantTurnSplit } from "../utils/messageContent"; +import type { ChatMessage } from "../hooks/useChat"; import AssistantProcessSummary from "./AssistantProcessSummary"; import ChatTitleBar from "./ChatTitleBar"; @@ -20,6 +21,23 @@ function thinkingSplit(content = "Thinking content"): AssistantTurnSplit { }; } +function runningToolSplit(name = "read_file"): AssistantTurnSplit { + const message: ChatMessage = { + id: "tool-1", + role: "assistant", + content: "", + status: "streaming", + timestamp: Date.now(), + toolData: { name, arguments: "{}" }, + }; + return { + tools: [message], + thinkings: [], + processSteps: [{ kind: "tool", message }], + answerMessage: null, + }; +} + function TitleBar() { return ( { expect(screen.queryByText("Thinking content")).not.toBeInTheDocument(); }); + it("shows a live hint while a tool is running", () => { + render(); + expect( + screen.getByText(/正在调用 read_file|Calling read_file/), + ).toBeInTheDocument(); + expect(screen.getByRole("button")).toHaveAttribute("aria-busy", "true"); + }); + + it("shows an organizing hint between tool rounds", () => { + const message: ChatMessage = { + id: "tool-done", + role: "assistant", + content: "", + status: "done", + timestamp: Date.now(), + toolData: { name: "read_file", arguments: "{}", output: "ok" }, + }; + const split: AssistantTurnSplit = { + tools: [message], + thinkings: [], + processSteps: [{ kind: "tool", message }], + answerMessage: null, + }; + render(); + expect( + screen.getByText(/整理结果中|Organizing results/), + ).toBeInTheDocument(); + }); + it("saves the menu choice and applies it to mounted panels and later visits", async () => { const { unmount } = render( <> diff --git a/dashboard/src/pages/Chat/components/AssistantProcessSummary.tsx b/dashboard/src/pages/Chat/components/AssistantProcessSummary.tsx index d5b15d4bc6..98b987790e 100644 --- a/dashboard/src/pages/Chat/components/AssistantProcessSummary.tsx +++ b/dashboard/src/pages/Chat/components/AssistantProcessSummary.tsx @@ -22,6 +22,54 @@ interface AssistantProcessSummaryProps { agentId?: string | null; } +function resolveLiveProcessHint( + split: AssistantTurnSplit, + isStreaming: boolean, + t: (key: string, opts?: Record) => string, +): string | null { + if (!isStreaming) return null; + + const runningTool = split.processSteps.find( + (step) => + step.kind === "tool" && + step.message.status === "streaming" && + !step.message.toolData?.output, + ); + if (runningTool && runningTool.kind === "tool") { + const name = + runningTool.message.toolData?.displayName?.trim() || + runningTool.message.toolData?.name?.trim() || + ""; + return name + ? t("chat.processRunningTool", { + name, + defaultValue: "正在调用 {{name}}", + }) + : t("chat.processRunningTools", { + defaultValue: "正在调用工具", + }); + } + + const thinkingLive = split.processSteps.some( + (step) => step.kind === "thinking" && step.item.isStreaming, + ); + if (thinkingLive) { + return t("chat.processThinkingLive", { defaultValue: "深度思考中" }); + } + + // Tool finished, next tokens not yet — keep the fold "alive" so it does + // not look stuck on a static count between ReAct rounds. + const hasProcess = + split.tools.length > 0 || + split.thinkings.length > 0 || + split.processSteps.length > 0; + if (hasProcess) { + return t("chat.processOrganizing", { defaultValue: "整理结果中" }); + } + + return null; +} + /** Foldable thinking + plain tools only (no rich plugin UI). */ function AssistantProcessSummary({ split, @@ -40,18 +88,32 @@ function AssistantProcessSummary({ () => countProcessStats(statsSplit ?? split), [statsSplit, split], ); + const liveHint = useMemo( + () => resolveLiveProcessHint(split, isStreaming, t), + [split, isStreaming, t], + ); // Manual toggles hold until streaming or the display preference changes. // History stays collapsed, and generation respects the saved preference. + // Only collapse when streaming ends — never flicker closed between tool rounds. useEffect(() => { if ( prevStreaming.current === isStreaming && prevCollapseThinking.current === collapseThinking ) return; + const wasStreaming = prevStreaming.current; prevStreaming.current = isStreaming; prevCollapseThinking.current = collapseThinking; - setExpanded(isStreaming && !collapseThinking); + if (isStreaming) { + setExpanded(!collapseThinking); + return; + } + if (wasStreaming) { + setExpanded(false); + } else if (collapseThinking) { + setExpanded(false); + } }, [isStreaming, collapseThinking]); if (toolCount === 0 && thinkingCount === 0) return null; @@ -77,11 +139,25 @@ function AssistantProcessSummary({
- )} + {label}
); diff --git a/dashboard/src/pages/Chat/components/MessageList.tsx b/dashboard/src/pages/Chat/components/MessageList.tsx index dfe152ab7e..17b227fd98 100644 --- a/dashboard/src/pages/Chat/components/MessageList.tsx +++ b/dashboard/src/pages/Chat/components/MessageList.tsx @@ -28,6 +28,8 @@ import { groupConsecutiveAssistantMessages, type MessageGroup, } from "../utils/messageGrouping"; +import { resolveLiveSpeakerNames } from "../utils/liveSpeakerLabel"; +import { useAgent } from "../../../context/AgentContext"; import { nextCanLoadOlder, shouldAutoFillOlderHistory, @@ -93,9 +95,10 @@ interface MessageListProps { onLoadMoreHistory?: () => boolean | void | Promise; onRefreshHistory?: () => void; isStreaming?: boolean; + /** Speakers still generating (tool gaps after composer unlock). */ + liveSpeakers?: ReadonlyArray; thinkingStartedAt?: number | null; sessionKey?: string; - onCancel?: () => void; onRegenerate?: (messageId: string) => void; onEditUserMessage?: (messageId: string, newText: string) => void; onForkAssistantMessage?: (messageId: string) => void; @@ -117,6 +120,7 @@ interface GroupRenderContext { agentId?: string | null; composerLookups?: ComposerTagLookups; isStreaming?: boolean; + liveSpeakers?: ReadonlyArray; lastBrowserGroupIndex: number; lastAssistantGroupIndex: number; lastUserGroupIndex: number; @@ -167,6 +171,7 @@ function renderMessageGroup( messages={[msg]} agentId={ctx.agentId} isTurnInProgress={isTurnInProgress} + liveSpeakers={ctx.liveSpeakers} onRegenerate={ctx.onRegenerate} onEditUserMessage={ctx.onEditUserMessage} onForkAssistantMessage={ctx.onForkAssistantMessage} @@ -219,6 +224,7 @@ function renderMessageGroup( messages={group.messages} agentId={ctx.agentId} isTurnInProgress={isTurnInProgress} + liveSpeakers={ctx.liveSpeakers} onRegenerate={ctx.onRegenerate} onEditUserMessage={ctx.onEditUserMessage} onForkAssistantMessage={ctx.onForkAssistantMessage} @@ -249,9 +255,9 @@ export default function MessageList(props: MessageListProps) { onLoadMoreHistory, onRefreshHistory, isStreaming, + liveSpeakers = [], thinkingStartedAt = null, sessionKey, - onCancel, onRegenerate, onEditUserMessage, onForkAssistantMessage, @@ -269,6 +275,7 @@ export default function MessageList(props: MessageListProps) { } = props; const { t } = useTranslation(); + const { agents, activeAgent } = useAgent(); const virtuosoRef = useRef(null); const wrapperRef = useRef(null); const containerRef = useRef(null); @@ -297,12 +304,26 @@ export default function MessageList(props: MessageListProps) { useVirtualLocked || messageGroups.length >= VIRTUALIZE_THRESHOLD; const lastMsg = messages[messages.length - 1]; - const { showFooter: showGenerating, showElapsed: isAwaitingAssistantReply } = - chatGeneratingPhase({ - isStreaming: Boolean(isStreaming), - loading: Boolean(loading), - lastMessageRole: lastMsg?.role, - }); + const hasLiveSpeakers = liveSpeakers.length > 0; + const { + showFooter: showGenerating, + showElapsed: isAwaitingAssistantReply, + membersOnly: generatingMembersOnly, + } = chatGeneratingPhase({ + isStreaming: Boolean(isStreaming), + loading: Boolean(loading), + lastMessageRole: lastMsg?.role, + hasLiveSpeakers, + }); + const liveSpeakerNames = useMemo( + () => + resolveLiveSpeakerNames(liveSpeakers, agents, { + hostId: activeAgent?.agent_id, + hostName: activeAgent?.name, + limit: 2, + }), + [liveSpeakers, agents, activeAgent?.agent_id, activeAgent?.name], + ); const stableSessionKey = sessionKey || "__default__"; @@ -683,6 +704,7 @@ export default function MessageList(props: MessageListProps) { agentId, composerLookups, isStreaming, + liveSpeakers, lastBrowserGroupIndex, lastAssistantGroupIndex, lastUserGroupIndex, @@ -705,6 +727,7 @@ export default function MessageList(props: MessageListProps) { agentId, composerLookups, isStreaming, + liveSpeakers, lastBrowserGroupIndex, lastAssistantGroupIndex, lastUserGroupIndex, @@ -733,7 +756,8 @@ export default function MessageList(props: MessageListProps) {
)} @@ -744,7 +768,8 @@ export default function MessageList(props: MessageListProps) { showGenerating, thinkingStartedAt, isAwaitingAssistantReply, - onCancel, + generatingMembersOnly, + liveSpeakerNames, refreshFooter, ], ); @@ -845,6 +870,7 @@ export default function MessageList(props: MessageListProps) { scrollToBottom()} /> diff --git a/dashboard/src/pages/Chat/components/ScrollToBottomButton.tsx b/dashboard/src/pages/Chat/components/ScrollToBottomButton.tsx index 99fe0c7be6..b1cfddccc5 100644 --- a/dashboard/src/pages/Chat/components/ScrollToBottomButton.tsx +++ b/dashboard/src/pages/Chat/components/ScrollToBottomButton.tsx @@ -6,14 +6,19 @@ import styles from "../index.module.less"; interface ScrollToBottomButtonProps { visible: boolean; onClick: () => void; + /** Live generation below the fold — emphasize "new replies". */ + hasNewActivity?: boolean; } export default function ScrollToBottomButton({ visible, onClick, + hasNewActivity = false, }: ScrollToBottomButtonProps) { const { t } = useTranslation(); - const label = t("chat.scrollToBottom"); + const label = hasNewActivity + ? t("chat.newRepliesBelow", { defaultValue: "有新回复" }) + : t("chat.scrollToBottom"); const buttonRef = useRef(null); const [overlapsTool, setOverlapsTool] = useState(false); @@ -65,7 +70,7 @@ export default function ScrollToBottomButton({ window.removeEventListener("resize", updateOverlap); resizeObserver?.disconnect(); }; - }, [visible]); + }, [visible, hasNewActivity]); const actuallyVisible = visible && !overlapsTool; return ( @@ -75,6 +80,10 @@ export default function ScrollToBottomButton({ actuallyVisible ? styles.scrollToBottomBtnVisible : styles.scrollToBottomBtnHidden + }${ + hasNewActivity && actuallyVisible + ? ` ${styles.scrollToBottomBtnHasNew}` + : "" }`} onClick={onClick} type="button" diff --git a/dashboard/src/pages/Chat/components/generatingGate.test.ts b/dashboard/src/pages/Chat/components/generatingGate.test.ts index a86b4e91d9..98eab4df41 100644 --- a/dashboard/src/pages/Chat/components/generatingGate.test.ts +++ b/dashboard/src/pages/Chat/components/generatingGate.test.ts @@ -17,6 +17,15 @@ describe("shouldShowGenerating", () => { false, ); }); + + it("stays true for late team members after the host unlocks the composer", () => { + expect( + shouldShowGenerating({ + isStreaming: false, + hasLiveSpeakers: true, + }), + ).toBe(true); + }); }); describe("chatGeneratingPhase", () => { @@ -27,7 +36,11 @@ describe("chatGeneratingPhase", () => { loading: false, lastMessageRole: "user", }), - ).toEqual({ showFooter: true, showElapsed: true }); + ).toEqual({ + showFooter: true, + showElapsed: true, + membersOnly: false, + }); }); it("hides elapsed once an assistant bubble exists", () => { @@ -36,7 +49,11 @@ describe("chatGeneratingPhase", () => { isStreaming: true, lastMessageRole: "assistant", }), - ).toEqual({ showFooter: true, showElapsed: false }); + ).toEqual({ + showFooter: true, + showElapsed: false, + membersOnly: false, + }); }); it("hides footer during initial history load", () => { @@ -46,6 +63,24 @@ describe("chatGeneratingPhase", () => { loading: true, lastMessageRole: "user", }), - ).toEqual({ showFooter: false, showElapsed: false }); + ).toEqual({ + showFooter: false, + showElapsed: false, + membersOnly: false, + }); + }); + + it("shows a members-only footer without cancel-phase elapsed", () => { + expect( + chatGeneratingPhase({ + isStreaming: false, + hasLiveSpeakers: true, + lastMessageRole: "assistant", + }), + ).toEqual({ + showFooter: true, + showElapsed: false, + membersOnly: true, + }); }); }); diff --git a/dashboard/src/pages/Chat/components/generatingGate.ts b/dashboard/src/pages/Chat/components/generatingGate.ts index aff954c2cc..0891bc87d4 100644 --- a/dashboard/src/pages/Chat/components/generatingGate.ts +++ b/dashboard/src/pages/Chat/components/generatingGate.ts @@ -1,12 +1,21 @@ /** * Whether the chat footer should show the unified "generating" indicator. * One bottom status for the whole turn (no separate thinking / continuing). + * + * Team rooms clear ``isStreaming`` when the host unlocks the composer, but + * members may still be generating — ``hasLiveSpeakers`` keeps the footer up. + * + * Stop/cancel lives only on the composer send button — this footer is status + * text only, so it can stay visible for the entire live turn. */ export function shouldShowGenerating(opts: { isStreaming: boolean; loading?: boolean; + /** Speakers still producing tokens/tools after the host turn settled. */ + hasLiveSpeakers?: boolean; }): boolean { - return Boolean(opts.isStreaming && !opts.loading); + const live = Boolean(opts.isStreaming) || Boolean(opts.hasLiveSpeakers); + return Boolean(live && !opts.loading); } /** @@ -17,10 +26,20 @@ export function chatGeneratingPhase(opts: { isStreaming: boolean; loading?: boolean; lastMessageRole?: string | null; -}): { showFooter: boolean; showElapsed: boolean } { + hasLiveSpeakers?: boolean; +}): { + showFooter: boolean; + showElapsed: boolean; + membersOnly: boolean; +} { const showFooter = shouldShowGenerating(opts); + const membersOnly = Boolean( + showFooter && !opts.isStreaming && opts.hasLiveSpeakers, + ); const showElapsed = Boolean( - showFooter && (!opts.lastMessageRole || opts.lastMessageRole === "user"), + showFooter && + !membersOnly && + (!opts.lastMessageRole || opts.lastMessageRole === "user"), ); - return { showFooter, showElapsed }; + return { showFooter, showElapsed, membersOnly }; } diff --git a/dashboard/src/pages/Chat/components/liveAssistantTurn.test.ts b/dashboard/src/pages/Chat/components/liveAssistantTurn.test.ts index 143d32107f..d882738e9f 100644 --- a/dashboard/src/pages/Chat/components/liveAssistantTurn.test.ts +++ b/dashboard/src/pages/Chat/components/liveAssistantTurn.test.ts @@ -36,14 +36,14 @@ describe("isLiveAssistantTurn", () => { ).toBe(false); }); - it("is false for earlier assistant groups while a later one is live", () => { + it("keeps earlier team speakers live while a later one is also streaming", () => { expect( isLiveAssistantTurn({ isStreaming: true, groupIndex: 1, lastAssistantGroupIndex: 3, - lastUserGroupIndex: 2, + lastUserGroupIndex: 0, }), - ).toBe(false); + ).toBe(true); }); }); diff --git a/dashboard/src/pages/Chat/components/liveAssistantTurn.ts b/dashboard/src/pages/Chat/components/liveAssistantTurn.ts index f6f3b7f491..fe695f9fd7 100644 --- a/dashboard/src/pages/Chat/components/liveAssistantTurn.ts +++ b/dashboard/src/pages/Chat/components/liveAssistantTurn.ts @@ -1,10 +1,13 @@ /** - * Whether this assistant group is the live in-progress turn. + * Whether this assistant group is part of the live in-progress turn. * - * After the user sends a new message, the previous assistant group is still - * `lastAssistantGroupIndex` until the model replies — it must NOT be treated - * as live, or its process summary re-expands and pushes the new user bubble - * off-screen. + * Any assistant group after the latest user message stays live while the + * session is streaming so concurrent team speakers do not collapse their + * process panels between tool rounds when another speaker is last in the list. + * + * After the user sends a new message, older assistant groups sit *before* + * ``lastUserGroupIndex`` and must NOT be treated as live (or their process + * summary re-expands and pushes the new user bubble off-screen). */ export function isLiveAssistantTurn(opts: { isStreaming: boolean; @@ -12,15 +15,6 @@ export function isLiveAssistantTurn(opts: { lastAssistantGroupIndex: number; lastUserGroupIndex: number; }): boolean { - const { - isStreaming, - groupIndex, - lastAssistantGroupIndex, - lastUserGroupIndex, - } = opts; - return ( - isStreaming && - groupIndex === lastAssistantGroupIndex && - groupIndex > lastUserGroupIndex - ); + const { isStreaming, groupIndex, lastUserGroupIndex } = opts; + return isStreaming && groupIndex > lastUserGroupIndex; } diff --git a/dashboard/src/pages/Chat/hooks/chatStore.teamStream.test.ts b/dashboard/src/pages/Chat/hooks/chatStore.teamStream.test.ts index f8424a7937..1d1b4b7b9e 100644 --- a/dashboard/src/pages/Chat/hooks/chatStore.teamStream.test.ts +++ b/dashboard/src/pages/Chat/hooks/chatStore.teamStream.test.ts @@ -701,4 +701,51 @@ describe("team member live stream", () => { expect(member[0]?.content).toBe("please rest"); expect(member[0]?.status).toBe("done"); }); + + it("keeps a member live across tool gaps after the host unlocks the composer", () => { + ingestHarnessChunk(SESSION, { type: "token", content: "dispatch" }); + ingestHarnessChunk(SESSION, { type: "done" }); + expect(getSnapshot(SESSION).isStreaming).toBe(false); + + ingestHarnessChunk(SESSION, { + type: "token", + content: "我先查一下", + agent_id: "doctor", + }); + expect(getSnapshot(SESSION).liveSpeakers).toContain("doctor"); + + ingestHarnessChunk(SESSION, { + type: "tool_call_chunk", + index: 0, + id: "read-1", + name: "read_file", + args: "{}", + agent_id: "doctor", + }); + ingestHarnessChunk(SESSION, { + type: "tool_result", + messages: [{ tool_call_id: "read-1", content: "labs ok" }], + agent_id: "doctor", + }); + + const afterTool = getSnapshot(SESSION); + expect(afterTool.isStreaming).toBe(false); + expect(afterTool.liveSpeakers).toContain("doctor"); + expect( + afterTool.messages.some( + (item) => + item.speakerAgentId === "doctor" && item.status === "streaming", + ), + ).toBe(false); + + ingestHarnessChunk(SESSION, { + type: "token", + content: "结论如下", + agent_id: "doctor", + }); + expect(getSnapshot(SESSION).liveSpeakers).toContain("doctor"); + + ingestHarnessChunk(SESSION, { type: "done", agent_id: "doctor" }); + expect(getSnapshot(SESSION).liveSpeakers).not.toContain("doctor"); + }); }); diff --git a/dashboard/src/pages/Chat/hooks/chatStore.ts b/dashboard/src/pages/Chat/hooks/chatStore.ts index 919b39d8f1..ee5817c445 100644 --- a/dashboard/src/pages/Chat/hooks/chatStore.ts +++ b/dashboard/src/pages/Chat/hooks/chatStore.ts @@ -150,6 +150,7 @@ function sealInFlightAssistantMessages(state: SessionStreamState): void { state.messages = state.messages.map((m) => m.status === "streaming" ? { ...m, status: "done" as const } : m, ); + clearAllLiveSpeakers(state); } // ── Pending prefill text ────────────────────────────────────────────────── @@ -253,6 +254,7 @@ const EMPTY_SNAPSHOT: SessionSnapshot = Object.freeze({ historyNextOffset: 0, historyHydrated: false, pendingPlanPath: null, + liveSpeakers: [] as string[], }); const sessionStates = new Map(); @@ -384,9 +386,38 @@ function buildSnapshot(state: SessionStreamState): SessionSnapshot { historyNextCursor: state.historyNextCursor, historyHydrated: state.historyHydrated, pendingPlanPath: state.pendingPlanPath ?? null, + liveSpeakers: [...state.liveSpeakers].sort(), }; } +function speakerLiveKey(speaker?: string): string { + return (speaker || "").trim(); +} + +/** Mark a speaker as still generating (survives tool gaps until ``done``). */ +function markSpeakerLive(state: SessionStreamState, speaker?: string): void { + const key = speakerLiveKey(speaker); + state.liveSpeakers.add(key); + // Unlabeled host frames also light up the room id so stamped host groups match. + if (!key) { + const host = state.roomAgentId?.trim(); + if (host) state.liveSpeakers.add(host); + } +} + +function clearSpeakerLive(state: SessionStreamState, speaker?: string): void { + const key = speakerLiveKey(speaker); + state.liveSpeakers.delete(key); + if (!key) { + const host = state.roomAgentId?.trim(); + if (host) state.liveSpeakers.delete(host); + } +} + +function clearAllLiveSpeakers(state: SessionStreamState): void { + state.liveSpeakers.clear(); +} + function getOrCreate(sessionId: string): SessionStreamState { let state = sessionStates.get(sessionId); if (!state) { @@ -411,6 +442,7 @@ function getOrCreate(sessionId: string): SessionStreamState { _snapshot: EMPTY_SNAPSHOT, roomAgentId: undefined, isTeamRoom: false, + liveSpeakers: new Set(), }; sessionStates.set(sessionId, state); } @@ -430,12 +462,15 @@ function notify(state: SessionStreamState) { } function beginStream(state: SessionStreamState, sessionId: string): void { + clearAllLiveSpeakers(state); state.thinkingStartedAt = Date.now(); state.isStreaming = true; touchStreamActivity(sessionId); } function clearStreamingFlags(state: SessionStreamState): void { + // Do not clear liveSpeakers — team members may keep generating after the + // host turn unlocks the composer; process panels key off liveSpeakers. state.isStreaming = false; state.thinkingStartedAt = null; } @@ -744,6 +779,7 @@ export function clearMessages(sessionId: string) { if (alreadyEmpty) return; state.messages = []; clearStreamingFlags(state); + clearAllLiveSpeakers(state); state.runUsage = null; usageSamplesByState.delete(state); state.streamMsg = ""; @@ -1104,6 +1140,7 @@ function handleHarnessChunk( } switch (chunk.type) { case "token": + markSpeakerLive(state, speaker); appendStreamingToken( state, chunk.content, @@ -1113,15 +1150,19 @@ function handleHarnessChunk( ); break; case "reasoning": + markSpeakerLive(state, speaker); appendStreamingReasoning(state, chunk.content, speaker); break; case "usage": applyUsageChunk(state, chunk); break; case "tool_call_chunk": + markSpeakerLive(state, speaker); upsertToolCall(state, chunk, sessionId, speaker); break; case "tool_result": + // Keep the speaker live across the tool→next-token gap. + markSpeakerLive(state, speaker); closeToolCall(state, chunk.messages, sessionId, speaker); break; case "done": @@ -1134,6 +1175,7 @@ function handleHarnessChunk( } if (Boolean(chunk.team_wrapup)) { finalizeWrapupMessages(state, speaker); + clearSpeakerLive(state, speaker); break; } finalizeStreamingMessages( @@ -1141,6 +1183,7 @@ function handleHarnessChunk( speaker, sessionHostAgentId(state, sessionId), ); + clearSpeakerLive(state, speaker); // Host done always frees the composer. Member bubbles may still stream. if (!speaker || (!hasStreamingMessages(state) && state.isStreaming)) { clearStreamingFlags(state); @@ -2054,6 +2097,7 @@ function handleHitlRequired( ): void { finalizeStreamingMessages(state); clearStreamingFlags(state); + clearAllLiveSpeakers(state); state.messages = [ ...state.messages, { diff --git a/dashboard/src/pages/Chat/hooks/sseHelpers.ts b/dashboard/src/pages/Chat/hooks/sseHelpers.ts index 0cc3f5cba1..e521f3a6ad 100644 --- a/dashboard/src/pages/Chat/hooks/sseHelpers.ts +++ b/dashboard/src/pages/Chat/hooks/sseHelpers.ts @@ -134,6 +134,12 @@ export interface SessionStreamState { /** Team host room — listen-only sockets and ask_agent continue stay on. */ isTeamRoom?: boolean; pendingPlanPath?: string | null; + /** + * Speakers that still own an open generation (token / tool / reasoning) + * until their ``done`` frame. Empty string = unlabeled host. Keeps process + * panels open across tool gaps after the composer has unlocked. + */ + liveSpeakers: Set; } /** Read-only snapshot shape exposed via ``chatStore.getSnapshot``. */ @@ -149,4 +155,6 @@ export interface SessionSnapshot { historyNextCursor?: string | null; historyHydrated: boolean; pendingPlanPath?: string | null; + /** Sorted speaker keys still generating (see SessionStreamState.liveSpeakers). */ + liveSpeakers: string[]; } diff --git a/dashboard/src/pages/Chat/hooks/useChat.ts b/dashboard/src/pages/Chat/hooks/useChat.ts index 9ec502a997..05c64d3b8d 100644 --- a/dashboard/src/pages/Chat/hooks/useChat.ts +++ b/dashboard/src/pages/Chat/hooks/useChat.ts @@ -840,6 +840,7 @@ export function useChat( historyLoadingMore, historyHydrated, pendingPlanPath, + liveSpeakers, } = useSyncExternalStore(subscribeStore, getStoreSnapshot); const [historyLoading, setHistoryLoading] = useState(false); @@ -1190,6 +1191,7 @@ export function useChat( historyRefreshing, historyHydrated, pendingPlanPath, + liveSpeakers, sendMessage, editAndResend, cancelStream, diff --git a/dashboard/src/pages/Chat/index.tsx b/dashboard/src/pages/Chat/index.tsx index 8691116784..ed029e2516 100644 --- a/dashboard/src/pages/Chat/index.tsx +++ b/dashboard/src/pages/Chat/index.tsx @@ -345,6 +345,7 @@ function ChatPageInner() { historyHydrated, contextUsage, pendingPlanPath, + liveSpeakers, sendMessage, editAndResend, cancelStream, @@ -1313,7 +1314,7 @@ function ChatPageInner() { welcomeSuffix={welcomeSuffix} quickCards={expertQuickCards} onPromptClick={handlePromptClick} - hideMascot={isStreaming} + hideMascot={isStreaming || liveSpeakers.length > 0} isTeam={isTeamChat} /> ) : ( @@ -1338,9 +1339,9 @@ function ChatPageInner() { onLoadMoreHistory={loadMoreHistory} onRefreshHistory={refreshHistory} isStreaming={isStreaming} + liveSpeakers={liveSpeakers} thinkingStartedAt={thinkingStartedAt} sessionKey={activeThreadId ?? undefined} - onCancel={cancelStream} onRegenerate={handleRegenerate} onEditUserMessage={handleEditUserMessage} onForkAssistantMessage={handleForkAssistantMessage} diff --git a/dashboard/src/pages/Chat/utils/chatAttachments.ts b/dashboard/src/pages/Chat/utils/chatAttachments.ts index cc822060da..6be0c1e89c 100644 --- a/dashboard/src/pages/Chat/utils/chatAttachments.ts +++ b/dashboard/src/pages/Chat/utils/chatAttachments.ts @@ -1,10 +1,16 @@ import type { ChatAttachment } from "../hooks/sseHelpers"; import { getMediaKind } from "../../Agent/Workspace/utils/mediaKind"; -const THINKING_TAG_RE = /[\s\S]*?<\/redacted_thinking>\s*/gi; +const THINKING_TAG_RE = + /<(?:think|thinking)>[\s\S]*?<\/(?:think|thinking)>\s*/gi; export function stripThinkingTags(text: string): string { - return text.replace(THINKING_TAG_RE, "").trim(); + let result = text.replace(THINKING_TAG_RE, ""); + const unclosed = result.search(/<(?:think|thinking)>/i); + if (unclosed >= 0) { + result = result.slice(0, unclosed); + } + return result.trim(); } export function isImageMediaType(mime?: string): boolean { diff --git a/dashboard/src/pages/Chat/utils/liveSpeakerLabel.test.ts b/dashboard/src/pages/Chat/utils/liveSpeakerLabel.test.ts new file mode 100644 index 0000000000..1d7ed1fe18 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/liveSpeakerLabel.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + hasInFlightTool, + hasStreamingThinking, + resolveLiveSpeakerNames, +} from "./liveSpeakerLabel"; + +describe("resolveLiveSpeakerNames", () => { + const agents = [ + { agent_id: "host", name: "主持" }, + { agent_id: "doctor", name: "临床助手" }, + { agent_id: "nurse", name: "护理助手" }, + ]; + + it("maps member ids to display names", () => { + expect( + resolveLiveSpeakerNames(["doctor", "nurse"], agents, { + hostId: "host", + hostName: "主持", + }), + ).toEqual(["临床助手", "护理助手"]); + }); + + it("treats empty and host id as the host", () => { + expect( + resolveLiveSpeakerNames(["", "host"], agents, { + hostId: "host", + hostName: "团队主持", + }), + ).toEqual(["团队主持"]); + }); + + it("falls back to the raw id when the agent is unknown", () => { + expect(resolveLiveSpeakerNames(["mystery"], agents)).toEqual(["mystery"]); + }); + + it("caps the list", () => { + expect( + resolveLiveSpeakerNames(["doctor", "nurse", "mystery"], agents, { + limit: 2, + }), + ).toEqual(["临床助手", "护理助手"]); + }); +}); + +describe("hasInFlightTool", () => { + it("detects a streaming tool without output", () => { + expect( + hasInFlightTool([ + { status: "streaming", toolData: { name: "read_file" } }, + ]), + ).toBe(true); + }); + + it("ignores completed tools", () => { + expect( + hasInFlightTool([ + { + status: "done", + toolData: { name: "read_file", output: "ok" }, + }, + ]), + ).toBe(false); + }); +}); + +describe("hasStreamingThinking", () => { + it("detects streaming reasoning bubbles", () => { + expect( + hasStreamingThinking([{ status: "streaming", type: "reasoning" }]), + ).toBe(true); + }); + + it("detects thinking content blocks", () => { + expect( + hasStreamingThinking([ + { + status: "streaming", + contentBlocks: [{ type: "thinking" }], + }, + ]), + ).toBe(true); + }); +}); diff --git a/dashboard/src/pages/Chat/utils/liveSpeakerLabel.ts b/dashboard/src/pages/Chat/utils/liveSpeakerLabel.ts new file mode 100644 index 0000000000..cd22d12e4d --- /dev/null +++ b/dashboard/src/pages/Chat/utils/liveSpeakerLabel.ts @@ -0,0 +1,73 @@ +/** Resolve display names for speakers still generating in a team room. */ + +export interface NamedAgent { + agent_id: string; + name?: string | null; +} + +/** + * Pick up to ``limit`` readable names for ``liveSpeakers`` keys + * (``""`` / host id → hostName). + */ +export function resolveLiveSpeakerNames( + liveSpeakers: ReadonlyArray, + agents: ReadonlyArray, + opts?: { + hostId?: string | null; + hostName?: string | null; + limit?: number; + }, +): string[] { + const hostId = (opts?.hostId || "").trim(); + const hostName = (opts?.hostName || "").trim(); + const limit = opts?.limit ?? 2; + const names: string[] = []; + const seen = new Set(); + + for (const raw of liveSpeakers) { + const key = (raw || "").trim(); + const isHost = !key || (hostId && key === hostId); + const name = isHost + ? hostName || + agents.find((a) => a.agent_id === hostId)?.name?.trim() || + "" + : agents.find((a) => a.agent_id === key)?.name?.trim() || key; + const label = name.trim(); + if (!label || seen.has(label)) continue; + seen.add(label); + names.push(label); + if (names.length >= limit) break; + } + return names; +} + +/** True when any assistant tool bubble is still waiting on a result. */ +export function hasInFlightTool( + messages: ReadonlyArray<{ + status?: string; + toolData?: { output?: unknown } | null; + }>, +): boolean { + return messages.some( + (m) => + Boolean(m.toolData) && + m.status === "streaming" && + m.toolData?.output === undefined, + ); +} + +/** True when a thinking/reasoning bubble is still streaming. */ +export function hasStreamingThinking( + messages: ReadonlyArray<{ + status?: string; + type?: string; + toolData?: unknown; + contentBlocks?: ReadonlyArray<{ type: string }> | null; + }>, +): boolean { + return messages.some((m) => { + if (m.status !== "streaming" || m.toolData) return false; + if (m.type === "reasoning") return true; + return Boolean(m.contentBlocks?.some((block) => block.type === "thinking")); + }); +} diff --git a/dashboard/src/pages/Chat/utils/messageContent.ts b/dashboard/src/pages/Chat/utils/messageContent.ts index db6c3c5491..6e44286c4b 100644 --- a/dashboard/src/pages/Chat/utils/messageContent.ts +++ b/dashboard/src/pages/Chat/utils/messageContent.ts @@ -1,14 +1,24 @@ import type { ChatMessage } from "../hooks/useChat"; import { isBrowserToolName, isWriteToolName } from "../constants"; -const THINKING_TAG_RE = /[\s\S]*?<\/think>/g; +const THINKING_TAG_RE = /<(?:think|thinking)>[\s\S]*?<\/(?:think|thinking)>/gi; +const THINKING_OPEN_RE = /<(?:think|thinking)>/i; +const THINKING_CLOSE_RE = /<\/(?:think|thinking)>/i; -/** Strip `` blocks from visible assistant text. */ +/** Strip `` / `` blocks from visible assistant text. */ export function stripThinkTags(raw: string): string { let result = raw.replace(THINKING_TAG_RE, ""); - const unclosedIdx = result.indexOf(""); - if (unclosedIdx >= 0) { - result = result.slice(0, unclosedIdx); + // Orphan closing tag: drop the hidden prefix before it. + while (true) { + const close = result.search(THINKING_CLOSE_RE); + if (close < 0) break; + const open = result.search(THINKING_OPEN_RE); + if (open >= 0 && open < close) break; + result = result.slice(close).replace(THINKING_CLOSE_RE, ""); + } + const unclosed = result.search(THINKING_OPEN_RE); + if (unclosed >= 0) { + result = result.slice(0, unclosed); } return result.trim(); } @@ -105,6 +115,7 @@ function hasCompletedToolBetweenTexts(messages: ChatMessage[]): boolean { export function splitAssistantTurn( messages: ChatMessage[], + opts?: { joinAnswerFragments?: boolean }, ): AssistantTurnSplit { const tools: ChatMessage[] = []; const thinkings: ThinkingProcessItem[] = []; @@ -137,11 +148,17 @@ export function splitAssistantTurn( } } - // 1:1 ReAct (text → tool → conclusion) keeps the last bubble as the answer. - // Join only team fragments of the same speaker that were split across bubbles. + // 1:1 ReAct (text → tool → conclusion) keeps the last bubble as the answer + // once the turn settles. While generating (or for stamped team speakers), + // join fragments so the answer never shrinks mid-flight after a tool round. + const teamStamped = messages.some((item) => + Boolean((item.speakerAgentId || "").trim()), + ); const joinFragments = textParts.length > 1 && - (messages.some((item) => item.teamWrapup) || + (Boolean(opts?.joinAnswerFragments) || + teamStamped || + messages.some((item) => item.teamWrapup) || !hasCompletedToolBetweenTexts(messages)); const answerMessage = answerTemplate && textParts.length > 0 diff --git a/dashboard/src/pages/Chat/utils/messageGrouping.test.ts b/dashboard/src/pages/Chat/utils/messageGrouping.test.ts index 18cf12eb2f..de08f962c3 100644 --- a/dashboard/src/pages/Chat/utils/messageGrouping.test.ts +++ b/dashboard/src/pages/Chat/utils/messageGrouping.test.ts @@ -270,6 +270,24 @@ describe("groupConsecutiveAssistantMessages", () => { expect(split.answerMessage?.content).toBe("please rest"); }); + it("keeps member narration across tool rounds in the answer", () => { + const split = splitAssistantTurn([ + msg("assistant", "m1", { + content: "我先查一下资料", + speakerAgentId: "doctor", + }), + msg("assistant", "tool", { + speakerAgentId: "doctor", + toolData: { name: "read_file", arguments: "{}", output: "ok" }, + }), + msg("assistant", "m2", { + content: "结论如下", + speakerAgentId: "doctor", + }), + ]); + expect(split.answerMessage?.content).toBe("我先查一下资料 结论如下"); + }); + it("keeps a 1:1 ReAct conclusion as the answer", () => { const split = splitAssistantTurn([ msg("assistant", "host1", { content: "I will ask" }), diff --git a/dashboard/src/pages/Control/Terminal/components/AiPanel.tsx b/dashboard/src/pages/Control/Terminal/components/AiPanel.tsx index 5717c7618f..986b90fe03 100644 --- a/dashboard/src/pages/Control/Terminal/components/AiPanel.tsx +++ b/dashboard/src/pages/Control/Terminal/components/AiPanel.tsx @@ -7,7 +7,14 @@ import React, { } from "react"; import { useTranslation } from "react-i18next"; import { Button, Tooltip, Spin, Segmented } from "antd"; -import { X, PanelRight, PanelBottom, SquareTerminal, Send } from "lucide-react"; +import { + X, + PanelRight, + PanelBottom, + SquareTerminal, + Send, + Square, +} from "lucide-react"; import type { OctopAgent } from "../../../../context/AgentContext"; import { terminalAiApi } from "../../../../api/modules/terminalAi"; import type { TerminalContext } from "../../../../api/modules/terminalAi"; @@ -424,7 +431,6 @@ export default function AiPanel({ messages={messages} isStreaming={isStreaming} sessionKey={threadId ?? undefined} - onCancel={cancelStream} onRunShellCommand={handleRunShellCommand} shellCommandDisabled={shellDisabled} shellCommandDisabledTitle={shellDisabledTitle} @@ -472,11 +478,19 @@ export default function AiPanel({ ) : null} diff --git a/dashboard/src/pages/Experts/components/agentBackendForm.skillPackages.test.ts b/dashboard/src/pages/Experts/components/agentBackendForm.skillPackages.test.ts index 1186531522..ff442bd272 100644 --- a/dashboard/src/pages/Experts/components/agentBackendForm.skillPackages.test.ts +++ b/dashboard/src/pages/Experts/components/agentBackendForm.skillPackages.test.ts @@ -92,7 +92,7 @@ describe("skill package backend gates", () => { ).toBe(false); }); - it("blocks outbound acp_runner under directory sandbox", () => { + it("locks ACP runner UI under directory sandbox", () => { expect( blocksAcpOutboundFromConfig({ backend: { type: "local_shell", root_dir: "/", virtual_mode: true }, diff --git a/dashboard/src/pages/Experts/components/agentBackendForm.ts b/dashboard/src/pages/Experts/components/agentBackendForm.ts index 65bc7ca6b0..71abb49238 100644 --- a/dashboard/src/pages/Experts/components/agentBackendForm.ts +++ b/dashboard/src/pages/Experts/components/agentBackendForm.ts @@ -133,12 +133,12 @@ export function supportsHostSkillPackagesFromConfig( } /** - * Whether outbound ``acp_runner`` should be blocked for this backend. + * Whether the ACP page should lock runner enable/edit for this backend. * - * Scoped ``root_dir`` enables the Linux bwrap jail; host-spawned ACP runners - * would bypass it. Host root ``/`` and the agent workspace root are allowed - * (Windows defaults to the workspace). Non-local backends are blocked. - * Inbound ``octop acp`` (IDE → this agent) is unaffected. + * Scoped ``root_dir`` enables the Linux bwrap jail. Host root ``/`` and the + * agent workspace root are not treated as a jail (Windows defaults to the + * workspace). Non-local backends count as locked for that UI. The per-agent + * ``acp_runner`` tool toggle and inbound ``octop acp`` are unaffected. */ export function blocksAcpOutbound(options: { backendChoice: string; @@ -160,16 +160,21 @@ export function blocksAcpOutbound(options: { return true; } -/** Detect outbound-ACP block from an agent ``config`` blob. */ +/** Detect ACP runner-lock (sandbox) from an agent ``config`` blob. */ export function blocksAcpOutboundFromConfig( config: Record | null | undefined, workspaceDir?: string | null, ): boolean { const parsed = parseBackendSpec(config?.backend); + const workspaceRaw = config?.workspace_dir; + const fromConfig = + typeof workspaceRaw === "string" && workspaceRaw.trim() + ? workspaceRaw.trim() + : null; return blocksAcpOutbound({ backendChoice: parsed.backendChoice, rootDir: parsed.rootDir, - workspaceDir, + workspaceDir: workspaceDir ?? fromConfig, }); } diff --git a/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.module.less b/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.module.less index d894aa84ad..4668edde92 100644 --- a/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.module.less +++ b/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.module.less @@ -348,6 +348,31 @@ code { opacity: 0.75; } +.mirrorErrors { + margin-top: 8px; +} + +.mirrorErrorsTitle { + margin: 0 0 4px; + font-size: 12px; + font-weight: 600; + opacity: 0.9; +} + +.mirrorErrorsList { + margin: 0; + padding-left: 18px; + font-size: 12px; + line-height: 1.45; + opacity: 0.85; + word-break: break-word; + + code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; + } +} + .restartSubHint { margin-top: 4px; font-size: 12px; diff --git a/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx b/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx index bd49741106..a03eccebab 100644 --- a/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx +++ b/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx @@ -546,6 +546,21 @@ export default function UpdateConfig() { {t("advancedSettings.update.upgradeFailed")}:{" "} {progress.error}

+ {progress.mirror_errors && + progress.mirror_errors.length > 0 && ( +
+

+ {t("advancedSettings.update.mirrorErrorsTitle")} +

+
    + {progress.mirror_errors.map((err) => ( +
  • + {err} +
  • + ))} +
+
+ )}

{t("advancedSettings.update.manualUpgradeHint")}

diff --git a/dashboard/src/plugins/toolRenderers/builtin/BuiltinOctopUiFallback.tsx b/dashboard/src/plugins/toolRenderers/builtin/BuiltinOctopUiFallback.tsx index 8309f35755..c2c9031115 100644 --- a/dashboard/src/plugins/toolRenderers/builtin/BuiltinOctopUiFallback.tsx +++ b/dashboard/src/plugins/toolRenderers/builtin/BuiltinOctopUiFallback.tsx @@ -1,4 +1,5 @@ import type { CSSProperties } from "react"; +import { useTranslation } from "react-i18next"; import type { ToolRenderProps } from "../types"; import { isSilentPluginUiData } from "../isPinnedToolUi"; @@ -26,6 +27,7 @@ export function BuiltinOctopUiFallback({ status, output, }: ToolRenderProps) { + const { t } = useTranslation(); if (isSilentPluginUiData(data)) return null; const obj = data && typeof data === "object" && !Array.isArray(data) @@ -38,19 +40,29 @@ export function BuiltinOctopUiFallback({ toolName || "Tool result"; const entries = obj ? Object.entries(obj).filter(([k]) => k !== "title") : []; + const running = status === "running"; return (
{title}
{toolName || "tool"} - {status === "running" ? " · running" : ""} + {running ? ` · ${t("common.running", "Running")}` : ""}
{entries.length > 0 ? (
diff --git a/dashboard/src/plugins/toolRenderers/builtin/DefaultToolRenderer.tsx b/dashboard/src/plugins/toolRenderers/builtin/DefaultToolRenderer.tsx index 3a0411a4ca..5a4eed032a 100644 --- a/dashboard/src/plugins/toolRenderers/builtin/DefaultToolRenderer.tsx +++ b/dashboard/src/plugins/toolRenderers/builtin/DefaultToolRenderer.tsx @@ -49,6 +49,7 @@ export function DefaultToolRenderer({ displayName: displayNameProp, args, output, + status, isStreaming, hideMediaPreview = false, onAcpPermissionSelect, @@ -111,7 +112,12 @@ export function DefaultToolRenderer({ const hasMediaPreview = structuredOutput.images.length > 0 || structuredOutput.videos.length > 0; const hasResult = toolData.output !== undefined; - const completed = hasResult || (!isStreaming && hasMediaPreview); + const completed = + status === "done" || + status === "error" || + hasResult || + (!isStreaming && hasMediaPreview); + const running = !completed && (status === "running" || isStreaming); const mediaOnly = completed && hasMediaPreview && @@ -126,7 +132,7 @@ export function DefaultToolRenderer({ ); const statusLabel = completed ? t("common.done", "Done") - : isStreaming + : running ? t("common.running", "Running") : t("common.pending", "Pending"); const ToolIcon = builtinToolIcon(toolName); @@ -135,9 +141,12 @@ export function DefaultToolRenderer({
+ setRemember(e.target.checked)} + style={{ + margin: 0, + fontSize: 13, + color: "var(--fn-text-tertiary)", + }} + > + {t("login.remember")} + + +
setShowForgotHelp(false)} diff --git a/dashboard/src/utils/ssoPopup.ts b/dashboard/src/utils/ssoPopup.ts index 8f70af0c6e..09fd0be1f7 100644 --- a/dashboard/src/utils/ssoPopup.ts +++ b/dashboard/src/utils/ssoPopup.ts @@ -6,6 +6,9 @@ export type SsoPopupMessage = { redirect?: string; bind?: boolean; error?: string; + /** Present on successful login popups so the opener can store session-only tokens. */ + access_token?: string; + remember?: boolean; }; export function isSsoPopup(): boolean { From 0322d09ddd88b9fcf5c748e87551a184ec71c5cc Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Sat, 26 Sep 2026 22:31:44 +0800 Subject: [PATCH 025/126] fix(chat): default-collapse thinking in team rooms only (#1179) * fix(chat): default-collapse thinking in team rooms only Keep solo chat expanded by default, and store team/solo preferences under separate localStorage keys so toggling one does not affect the other. Co-authored-by: Cursor * fix(chat): keep team member replies in one bubble and clear stale live state Continue member answer text across completed tools in team rooms, and stop snapshots/wrap-up from leaving finished speakers marked live in the footer. Co-authored-by: Cursor --------- Co-authored-by: jubaoliang Co-authored-by: Cursor --- dashboard/src/locales/en.json | 2 +- dashboard/src/locales/zh.json | 2 +- .../AssistantProcessSummary.test.tsx | 45 ++++++++++-- .../components/AssistantProcessSummary.tsx | 5 +- .../pages/Chat/components/ChatTitleBar.tsx | 2 +- .../Chat/components/TurnProcessBlocks.tsx | 1 + .../Chat/hooks/chatStore.teamStream.test.ts | 68 ++++++++++++++++++- dashboard/src/pages/Chat/hooks/chatStore.ts | 37 ++++++++-- .../pages/Chat/hooks/useCollapseThinking.ts | 20 ++++-- .../pages/Chat/utils/messageGrouping.test.ts | 47 +++++++++++++ .../src/pages/Chat/utils/messageGrouping.ts | 9 ++- 11 files changed, 219 insertions(+), 19 deletions(-) diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 959072e43e..12ad4d7952 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -1263,7 +1263,7 @@ "hideThinking": "Hide thinking", "showThinking": "Show thinking", "collapseThinking": "Collapse thinking by default", - "collapseThinkingHint": "Keep thinking and tool calls collapsed while generating replies. You can still expand them manually. Saved in this browser.", + "collapseThinkingHint": "Team chats collapse thinking and tool panels by default; solo chats stay expanded. You can change either anytime. Each preference is saved in this browser.", "processSummary": "Called {{tools}} tools, {{thinking}} deep thoughts", "processSummaryToolsOnly": "Called {{tools}} tools", "processSummaryThinkingOnly": "{{thinking}} deep thoughts", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index b948936e14..51d5104850 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -1263,7 +1263,7 @@ "hideThinking": "隐藏思维过程", "showThinking": "查看思维过程", "collapseThinking": "默认折叠思考过程", - "collapseThinkingHint": "生成回复时默认折叠思考与工具调用面板,仍可手动展开。设置保存在当前浏览器。", + "collapseThinkingHint": "团队聊天默认折叠思考与工具面板,单聊默认展开。可随时切换;两类设置分别保存在当前浏览器。", "processSummary": "已调用 {{tools}} 次工具,{{thinking}} 次深度思考", "processSummaryToolsOnly": "已调用 {{tools}} 次工具", "processSummaryThinkingOnly": "{{thinking}} 次深度思考", diff --git a/dashboard/src/pages/Chat/components/AssistantProcessSummary.test.tsx b/dashboard/src/pages/Chat/components/AssistantProcessSummary.test.tsx index 44e579a616..aa0d1432b2 100644 --- a/dashboard/src/pages/Chat/components/AssistantProcessSummary.test.tsx +++ b/dashboard/src/pages/Chat/components/AssistantProcessSummary.test.tsx @@ -2,6 +2,10 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AssistantTurnSplit } from "../utils/messageContent"; import type { ChatMessage } from "../hooks/useChat"; +import { + COLLAPSE_THINKING_SOLO_KEY, + COLLAPSE_THINKING_TEAM_KEY, +} from "../hooks/useCollapseThinking"; import AssistantProcessSummary from "./AssistantProcessSummary"; import ChatTitleBar from "./ChatTitleBar"; @@ -38,7 +42,7 @@ function runningToolSplit(name = "read_file"): AssistantTurnSplit { }; } -function TitleBar() { +function TitleBar({ isTeam = false }: { isTeam?: boolean }) { return ( ); } @@ -70,6 +75,17 @@ describe("thinking display preference", () => { expect(screen.queryByText("Thinking content")).not.toBeInTheDocument(); }); + it("collapses by default while streaming in team chats", () => { + render( + , + ); + expect(screen.queryByText("Thinking content")).not.toBeInTheDocument(); + expect(screen.getByRole("button")).toHaveAttribute( + "aria-expanded", + "false", + ); + }); + it("shows a live hint while a tool is running", () => { render(); expect( @@ -114,7 +130,7 @@ describe("thinking display preference", () => { fireEvent.click( screen.getByRole("menuitem", { name: /chat.collapseThinking/ }), ); - expect(localStorage.getItem("octop:collapse-thinking")).toBe("true"); + expect(localStorage.getItem(COLLAPSE_THINKING_SOLO_KEY)).toBe("true"); expect(screen.queryByText("Thinking content")).not.toBeInTheDocument(); unmount(); render( @@ -130,8 +146,27 @@ describe("thinking display preference", () => { ).toBeChecked(); }); + it("defaults team menu switch on and stores team preference separately", async () => { + render( + <> + + + , + ); + expect(screen.queryByText("Thinking content")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "更多" })); + const preference = await screen.findByRole("switch", { + name: "chat.collapseThinking", + }); + expect(preference).toBeChecked(); + fireEvent.click(preference); + expect(localStorage.getItem(COLLAPSE_THINKING_TEAM_KEY)).toBe("false"); + expect(localStorage.getItem(COLLAPSE_THINKING_SOLO_KEY)).toBeNull(); + expect(screen.getByText("Thinking content")).toBeInTheDocument(); + }); + it("allows manual expansion during streaming without resetting on content updates", () => { - localStorage.setItem("octop:collapse-thinking", "true"); + localStorage.setItem(COLLAPSE_THINKING_SOLO_KEY, "true"); const { rerender } = render( , ); @@ -155,7 +190,7 @@ describe("thinking display preference", () => { }); it("can restore automatic expansion from the menu", async () => { - localStorage.setItem("octop:collapse-thinking", "true"); + localStorage.setItem(COLLAPSE_THINKING_SOLO_KEY, "true"); render( <> @@ -168,7 +203,7 @@ describe("thinking display preference", () => { }); expect(preference).toBeChecked(); fireEvent.click(preference); - expect(localStorage.getItem("octop:collapse-thinking")).toBe("false"); + expect(localStorage.getItem(COLLAPSE_THINKING_SOLO_KEY)).toBe("false"); expect(screen.getByText("Thinking content")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "更多" })); expect( diff --git a/dashboard/src/pages/Chat/components/AssistantProcessSummary.tsx b/dashboard/src/pages/Chat/components/AssistantProcessSummary.tsx index 98b987790e..b302e4b8c4 100644 --- a/dashboard/src/pages/Chat/components/AssistantProcessSummary.tsx +++ b/dashboard/src/pages/Chat/components/AssistantProcessSummary.tsx @@ -17,6 +17,8 @@ interface AssistantProcessSummaryProps { */ statsSplit?: AssistantTurnSplit; isStreaming?: boolean; + /** Team rooms default to collapsed thinking; solo stays expanded. */ + isTeam?: boolean; onAcpPermissionSelect?: (message: string) => void; hideToolMedia?: boolean; agentId?: string | null; @@ -75,12 +77,13 @@ function AssistantProcessSummary({ split, statsSplit, isStreaming = false, + isTeam = false, onAcpPermissionSelect, hideToolMedia = false, agentId = null, }: AssistantProcessSummaryProps) { const { t } = useTranslation(); - const [collapseThinking] = useCollapseThinking(); + const [collapseThinking] = useCollapseThinking(isTeam); const [expanded, setExpanded] = useState(isStreaming && !collapseThinking); const prevStreaming = useRef(isStreaming); const prevCollapseThinking = useRef(collapseThinking); diff --git a/dashboard/src/pages/Chat/components/ChatTitleBar.tsx b/dashboard/src/pages/Chat/components/ChatTitleBar.tsx index 164a4f3559..994330fbb6 100644 --- a/dashboard/src/pages/Chat/components/ChatTitleBar.tsx +++ b/dashboard/src/pages/Chat/components/ChatTitleBar.tsx @@ -42,7 +42,7 @@ export default function ChatTitleBar({ isTeam = false, }: ChatTitleBarProps) { const { t } = useTranslation(); - const [collapseThinking, setCollapseThinking] = useCollapseThinking(); + const [collapseThinking, setCollapseThinking] = useCollapseThinking(isTeam); const [isEditing, setIsEditing] = useState(false); const [editValue, setEditValue] = useState(title); const inputRef = useRef(null); diff --git a/dashboard/src/pages/Chat/components/TurnProcessBlocks.tsx b/dashboard/src/pages/Chat/components/TurnProcessBlocks.tsx index d6a09288c0..a7eedf8368 100644 --- a/dashboard/src/pages/Chat/components/TurnProcessBlocks.tsx +++ b/dashboard/src/pages/Chat/components/TurnProcessBlocks.tsx @@ -83,6 +83,7 @@ export function TurnProcessBlocks({ split={folded} statsSplit={split} isStreaming={isStreaming} + isTeam={isTeamRoom} onAcpPermissionSelect={onAcpPermissionSelect} hideToolMedia={hideToolMedia} agentId={agentId} diff --git a/dashboard/src/pages/Chat/hooks/chatStore.teamStream.test.ts b/dashboard/src/pages/Chat/hooks/chatStore.teamStream.test.ts index 1d1b4b7b9e..25b3a7364c 100644 --- a/dashboard/src/pages/Chat/hooks/chatStore.teamStream.test.ts +++ b/dashboard/src/pages/Chat/hooks/chatStore.teamStream.test.ts @@ -695,11 +695,77 @@ describe("team member live stream", () => { agent_id: "doctor", team_snapshot: true, }); - const { messages } = getSnapshot(SESSION); + const { messages, liveSpeakers } = getSnapshot(SESSION); const member = messages.filter((item) => item.speakerAgentId === "doctor"); expect(member).toHaveLength(1); expect(member[0]?.content).toBe("please rest"); expect(member[0]?.status).toBe("done"); + expect(liveSpeakers).not.toContain("doctor"); + }); + + it("keeps a member answer on one bubble across completed tools", () => { + ingestHarnessChunk(SESSION, { + type: "token", + content: "我先查一下。", + agent_id: "doctor", + }); + ingestHarnessChunk(SESSION, { + type: "tool_call_chunk", + index: 0, + id: "read-1", + name: "read_file", + args: "{}", + agent_id: "doctor", + }); + ingestHarnessChunk(SESSION, { + type: "tool_result", + messages: [{ tool_call_id: "read-1", content: "labs ok" }], + agent_id: "doctor", + }); + ingestHarnessChunk(SESSION, { + type: "token", + content: "结论如下。", + agent_id: "doctor", + }); + const member = getSnapshot(SESSION).messages.filter( + (item) => item.speakerAgentId === "doctor" && !item.toolData, + ); + expect(member).toHaveLength(1); + expect(member[0]?.content).toBe("我先查一下。结论如下。"); + }); + + it("clears stale member live state when host wrap-up finishes", () => { + ingestHarnessChunk(SESSION, { + type: "token", + content: "done work", + agent_id: "doctor", + }); + ingestHarnessChunk(SESSION, { type: "done", agent_id: "doctor" }); + // Snapshot must not re-light a finished speaker. + ingestHarnessChunk(SESSION, { + type: "token", + content: "done work", + agent_id: "doctor", + team_snapshot: true, + }); + expect(getSnapshot(SESSION).liveSpeakers).not.toContain("doctor"); + + ingestHarnessChunk( + SESSION, + { + type: "token", + content: "汇总完成", + agent_id: "host", + team_wrapup: true, + }, + "host", + ); + ingestHarnessChunk( + SESSION, + { type: "done", agent_id: "host", team_wrapup: true }, + "host", + ); + expect(getSnapshot(SESSION).liveSpeakers).toEqual([]); }); it("keeps a member live across tool gaps after the host unlocks the composer", () => { diff --git a/dashboard/src/pages/Chat/hooks/chatStore.ts b/dashboard/src/pages/Chat/hooks/chatStore.ts index ee5817c445..f6850a2d5f 100644 --- a/dashboard/src/pages/Chat/hooks/chatStore.ts +++ b/dashboard/src/pages/Chat/hooks/chatStore.ts @@ -408,9 +408,12 @@ function markSpeakerLive(state: SessionStreamState, speaker?: string): void { function clearSpeakerLive(state: SessionStreamState, speaker?: string): void { const key = speakerLiveKey(speaker); state.liveSpeakers.delete(key); + const host = state.roomAgentId?.trim(); + // Unlabeled and stamped host share one live bit — clear both together. if (!key) { - const host = state.roomAgentId?.trim(); if (host) state.liveSpeakers.delete(host); + } else if (host && key === host) { + state.liveSpeakers.delete(""); } } @@ -418,6 +421,25 @@ function clearAllLiveSpeakers(state: SessionStreamState): void { state.liveSpeakers.clear(); } +/** Drop live speakers with no streaming text and no in-flight tools. */ +function pruneIdleLiveSpeakers(state: SessionStreamState): void { + if (state.liveSpeakers.size === 0) return; + const busy = new Set(); + const host = state.roomAgentId?.trim(); + for (const message of state.messages) { + if (message.role !== "assistant") continue; + const key = speakerLiveKey(message.speakerAgentId); + const inFlightTool = Boolean(message.toolData && !message.toolData.output); + if (message.status !== "streaming" && !inFlightTool) continue; + busy.add(key); + if (!key && host) busy.add(host); + if (key && host && key === host) busy.add(""); + } + for (const key of [...state.liveSpeakers]) { + if (!busy.has(key)) state.liveSpeakers.delete(key); + } +} + function getOrCreate(sessionId: string): SessionStreamState { let state = sessionStates.get(sessionId); if (!state) { @@ -1139,16 +1161,20 @@ function handleHarnessChunk( touchStreamActivity(sessionId); } switch (chunk.type) { - case "token": - markSpeakerLive(state, speaker); + case "token": { + const snapshot = Boolean(chunk.team_snapshot); + // Snapshots are complete wall copies — never leave the speaker "live". + if (!snapshot) markSpeakerLive(state, speaker); appendStreamingToken( state, chunk.content, speaker, - Boolean(chunk.team_snapshot), + snapshot, Boolean(chunk.team_wrapup), ); + if (snapshot) clearSpeakerLive(state, speaker); break; + } case "reasoning": markSpeakerLive(state, speaker); appendStreamingReasoning(state, chunk.content, speaker); @@ -1176,6 +1202,9 @@ function handleHarnessChunk( if (Boolean(chunk.team_wrapup)) { finalizeWrapupMessages(state, speaker); clearSpeakerLive(state, speaker); + // Wrap-up means members already finished — drop stale live bits + // (e.g. snapshot re-marked a speaker after their done). + pruneIdleLiveSpeakers(state); break; } finalizeStreamingMessages( diff --git a/dashboard/src/pages/Chat/hooks/useCollapseThinking.ts b/dashboard/src/pages/Chat/hooks/useCollapseThinking.ts index 293706bd69..bfca50cfd5 100644 --- a/dashboard/src/pages/Chat/hooks/useCollapseThinking.ts +++ b/dashboard/src/pages/Chat/hooks/useCollapseThinking.ts @@ -1,9 +1,21 @@ import { useLocalStorageState } from "ahooks"; -/** Browser-wide display preference, shared by the menu and process panels. */ -export function useCollapseThinking() { - return useLocalStorageState("octop:collapse-thinking", { - defaultValue: false, +/** Solo / 1:1 chat preference (default: expand while streaming). */ +export const COLLAPSE_THINKING_SOLO_KEY = "octop:collapse-thinking"; +/** Team room preference (default: collapse while streaming). */ +export const COLLAPSE_THINKING_TEAM_KEY = "octop:collapse-thinking:team"; + +export function collapseThinkingStorageKey(isTeam: boolean): string { + return isTeam ? COLLAPSE_THINKING_TEAM_KEY : COLLAPSE_THINKING_SOLO_KEY; +} + +/** + * Browser display preference for folding thinking/tool panels while streaming. + * Team and solo chats use separate keys so toggling one does not affect the other. + */ +export function useCollapseThinking(isTeam = false) { + return useLocalStorageState(collapseThinkingStorageKey(isTeam), { + defaultValue: isTeam, listenStorageChange: true, }); } diff --git a/dashboard/src/pages/Chat/utils/messageGrouping.test.ts b/dashboard/src/pages/Chat/utils/messageGrouping.test.ts index de08f962c3..0be3fd143f 100644 --- a/dashboard/src/pages/Chat/utils/messageGrouping.test.ts +++ b/dashboard/src/pages/Chat/utils/messageGrouping.test.ts @@ -194,6 +194,53 @@ describe("groupConsecutiveAssistantMessages", () => { ).toBe(0); }); + it("keeps a team member answer on one bubble across completed tools", () => { + const messages = [ + msg("assistant", "member", { + content: "我先查一下", + speakerAgentId: "doctor", + status: "done", + }), + msg("assistant", "tool", { + toolData: { + name: "read_file", + arguments: "{}", + output: "labs ok", + }, + speakerAgentId: "doctor", + }), + ]; + expect( + findSpeakerTextToContinue(messages, "doctor", "host", { + teamRoom: true, + continueThroughTools: ["ask_agent"], + }), + ).toBe(0); + // Host still treats completed tools as a break. + expect( + findSpeakerTextToContinue( + [ + msg("assistant", "host", { + content: "稍等", + speakerAgentId: "host", + status: "done", + }), + msg("assistant", "tool", { + toolData: { + name: "ask_agent", + arguments: "{}", + output: "queued", + }, + speakerAgentId: "host", + }), + ], + "host", + "host", + { teamRoom: true, continueThroughTools: ["ask_agent"] }, + ), + ).toBe(-1); + }); + it("treats a completed ask_agent as a turn break", () => { const messages = [ msg("assistant", "host", { content: "I will ask" }), diff --git a/dashboard/src/pages/Chat/utils/messageGrouping.ts b/dashboard/src/pages/Chat/utils/messageGrouping.ts index 3f6c92986e..53c95da485 100644 --- a/dashboard/src/pages/Chat/utils/messageGrouping.ts +++ b/dashboard/src/pages/Chat/utils/messageGrouping.ts @@ -71,6 +71,9 @@ export function sameGroupedSpeaker( * ``continueThroughTools`` only skips *in-flight* tools (no output yet). A * completed ``ask_agent`` is a turn break so the host wrap-up after members * reply opens a new bubble instead of appending to the dispatch text. + * + * Team **members** keep one answer bubble across their own completed tools + * (normal ReAct). Host still treats completed tools as a break. */ export function findSpeakerTextToContinue( messages: ChatMessage[], @@ -80,6 +83,10 @@ export function findSpeakerTextToContinue( ): number { const skipTools = new Set(options?.continueThroughTools ?? []); const teamRoom = Boolean(options?.teamRoom); + const memberContinuesThroughTools = + teamRoom && + Boolean((speakerId || "").trim()) && + !isRoomHostSpeaker(speakerId, hostAgentId); let lastTool = -1; let lastText = -1; for (let i = messages.length - 1; i >= 0; i--) { @@ -99,7 +106,7 @@ export function findSpeakerTextToContinue( if (message.toolData) { const name = message.toolData.name || ""; const inFlight = skipTools.has(name) && !message.toolData.output; - if (inFlight) continue; + if (inFlight || memberContinuesThroughTools) continue; if (lastTool < 0) lastTool = i; continue; } From 0c57dbc80c993e30ae527e105112a4287454dc2f Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Sat, 26 Sep 2026 22:42:10 +0800 Subject: [PATCH 026/126] test(chat): cover member live bit across tool gaps after host unlock (#1181) Lock in that a team member stays in liveSpeakers through tool rounds even after the host done frees the composer. Co-authored-by: jubaoliang Co-authored-by: Cursor --- .../Chat/hooks/chatStore.teamStream.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/dashboard/src/pages/Chat/hooks/chatStore.teamStream.test.ts b/dashboard/src/pages/Chat/hooks/chatStore.teamStream.test.ts index 25b3a7364c..75e1ba67c1 100644 --- a/dashboard/src/pages/Chat/hooks/chatStore.teamStream.test.ts +++ b/dashboard/src/pages/Chat/hooks/chatStore.teamStream.test.ts @@ -814,4 +814,51 @@ describe("team member live stream", () => { ingestHarnessChunk(SESSION, { type: "done", agent_id: "doctor" }); expect(getSnapshot(SESSION).liveSpeakers).not.toContain("doctor"); }); + + it("keeps a member live across tool gaps after the host unlocks the composer", () => { + ingestHarnessChunk(SESSION, { type: "token", content: "dispatch" }); + ingestHarnessChunk(SESSION, { type: "done" }); + expect(getSnapshot(SESSION).isStreaming).toBe(false); + + ingestHarnessChunk(SESSION, { + type: "token", + content: "我先查一下", + agent_id: "doctor", + }); + expect(getSnapshot(SESSION).liveSpeakers).toContain("doctor"); + + ingestHarnessChunk(SESSION, { + type: "tool_call_chunk", + index: 0, + id: "read-1", + name: "read_file", + args: "{}", + agent_id: "doctor", + }); + ingestHarnessChunk(SESSION, { + type: "tool_result", + messages: [{ tool_call_id: "read-1", content: "labs ok" }], + agent_id: "doctor", + }); + + const afterTool = getSnapshot(SESSION); + expect(afterTool.isStreaming).toBe(false); + expect(afterTool.liveSpeakers).toContain("doctor"); + expect( + afterTool.messages.some( + (item) => + item.speakerAgentId === "doctor" && item.status === "streaming", + ), + ).toBe(false); + + ingestHarnessChunk(SESSION, { + type: "token", + content: "结论如下", + agent_id: "doctor", + }); + expect(getSnapshot(SESSION).liveSpeakers).toContain("doctor"); + + ingestHarnessChunk(SESSION, { type: "done", agent_id: "doctor" }); + expect(getSnapshot(SESSION).liveSpeakers).not.toContain("doctor"); + }); }); From ac8c233180b93ff45b08bc586763fb6f2bc7d0de Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Sat, 26 Sep 2026 22:43:09 +0800 Subject: [PATCH 027/126] feat(users): add role templates, invite snapshots, and profile avatars (#1180) * feat(users): add role templates, invite snapshots, and profile avatars Roles fill defaults for create, edit, and invite without rewriting existing users. Account type stays independent of the selected role. Co-authored-by: Cursor * style: format role and invite repositories for ruff Co-authored-by: Cursor * fix: narrow invite role name before lookup Co-authored-by: Cursor --------- Co-authored-by: jubaoliang Co-authored-by: Cursor --- CHANGELOG.md | 1 + dashboard/src/api/modules/auth.ts | 26 +- dashboard/src/api/modules/invites.ts | 4 + dashboard/src/api/modules/userRoles.ts | 149 ++++ .../src/components/AvatarDropdown.module.less | 35 + dashboard/src/components/AvatarDropdown.tsx | 89 ++- dashboard/src/locales/en.json | 78 +- dashboard/src/locales/zh.json | 78 +- .../src/pages/Admin/Users/InviteDrawer.tsx | 136 +++- .../src/pages/Admin/Users/ProfileAvatar.tsx | 279 +++++++ .../src/pages/Admin/Users/RolesPanel.tsx | 615 ++++++++++++++++ .../src/pages/Admin/Users/UsersListPanel.tsx | 690 +++++++++++++----- .../src/pages/Admin/Users/index.module.less | 531 ++++++++++++-- dashboard/src/pages/Admin/Users/index.tsx | 11 +- dashboard/src/utils/permissions.ts | 1 + dashboard/src/utils/toolMediaBlocks.ts | 2 + src/octop/api/app.py | 2 + src/octop/api/routers/auth.py | 51 +- src/octop/api/routers/invites.py | 25 + src/octop/api/routers/user_roles.py | 351 +++++++++ src/octop/api/routers/users.py | 139 +++- src/octop/cli/support/offline_ops.py | 15 + src/octop/i18n/en.json | 1 + src/octop/i18n/zh.json | 1 + src/octop/infra/db/migrate.py | 122 ++++ .../infra/db/migrations/018_user_role.pg.sql | 21 + .../infra/db/migrations/018_user_role.sql | 23 + src/octop/infra/db/repos/invites.py | 64 +- src/octop/infra/db/repos/user_roles.py | 254 +++++++ src/octop/infra/db/repos/users.py | 42 +- src/octop/infra/errors.py | 2 + src/octop/infra/users/invites.py | 62 +- src/octop/infra/users/manager.py | 18 +- src/octop/infra/users/permissions.py | 4 +- src/octop/infra/users/profile_avatar.py | 100 +++ src/octop/infra/utils/paths.py | 10 + tests/integration/test_auth_flow.py | 36 + tests/integration/test_user_roles_api.py | 280 +++++++ tests/integration/test_users_api.py | 18 + tests/unit/db/test_user_role.py | 114 +++ tests/unit/test_user_manager.py | 4 + 41 files changed, 4189 insertions(+), 295 deletions(-) create mode 100644 dashboard/src/api/modules/userRoles.ts create mode 100644 dashboard/src/pages/Admin/Users/ProfileAvatar.tsx create mode 100644 dashboard/src/pages/Admin/Users/RolesPanel.tsx create mode 100644 src/octop/api/routers/user_roles.py create mode 100644 src/octop/infra/db/migrations/018_user_role.pg.sql create mode 100644 src/octop/infra/db/migrations/018_user_role.sql create mode 100644 src/octop/infra/db/repos/user_roles.py create mode 100644 src/octop/infra/users/profile_avatar.py create mode 100644 tests/integration/test_user_roles_api.py create mode 100644 tests/unit/db/test_user_role.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 82fdd7683d..2cdb1e0a9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### 新增 +- 用户管理增加角色模板:创建、编辑和邀请时带出权限、资源策略与头像;修改角色不会改写已有用户的权限、策略或账号类型。用户与角色支持预设图标和上传头像。 - 登录页增加「记住登录状态」勾选(默认勾选):取消后 JWT 仅保存在当前标签页的 `sessionStorage`,关闭标签后需重新登录;SSO 弹窗通过 postMessage 回传 token - 企查查内置连接器恢复一键 OAuth:保留 internal HTTP 工具加载及已有 API Key,支持五类资源共享刷新与远程解绑;注明系统浏览器及公网 HTTPS 回调要求。 diff --git a/dashboard/src/api/modules/auth.ts b/dashboard/src/api/modules/auth.ts index e8acf3cfab..870f362242 100644 --- a/dashboard/src/api/modules/auth.ts +++ b/dashboard/src/api/modules/auth.ts @@ -1,4 +1,9 @@ -import { clearSetupRequired, markSetupRequired, request } from "../request"; +import { + clearSetupRequired, + markSetupRequired, + request, + requestUpload, +} from "../request"; /** * Auth + setup module — adapted to octop's multi-user backend. @@ -55,6 +60,10 @@ export interface OctopUser { /** Linked SSO providers (multi-identity). */ sso_identities?: SsoIdentity[]; has_password?: boolean; + /** Preset character id. Empty means the built-in default portrait. */ + avatar_icon?: string | null; + /** Uploaded portrait. Shown ahead of the preset when present. */ + avatar_url?: string | null; } export interface LoginResponse { @@ -255,6 +264,21 @@ export const authApi = { body: JSON.stringify({ display_name: displayName }), }), + /** Choose a preset icon. Null restores the built-in default and clears an upload. */ + setAvatarIcon: (icon: string | null) => + request("/auth/me", { + method: "PATCH", + body: JSON.stringify({ avatar_icon: icon }), + }), + + uploadAvatar: (file: File) => { + const body = new FormData(); + body.append("file", file); + return requestUpload<{ avatar_url: string | null }>("/auth/me/avatar", body); + }, + + deleteAvatar: () => request("/auth/me/avatar", { method: "DELETE" }), + // --- Legacy stubs kept so finnie-era components compile ---------------- // These call paths are removed in octop's data model; the actual // settings UI is rewritten in phase 14.6. Stubs return rejected diff --git a/dashboard/src/api/modules/invites.ts b/dashboard/src/api/modules/invites.ts index b343bd9b1e..79f727365f 100644 --- a/dashboard/src/api/modules/invites.ts +++ b/dashboard/src/api/modules/invites.ts @@ -15,11 +15,15 @@ export interface InviteRow { status: InviteStatus; invite_path: string; invite_url: string; + role_name?: string | null; + user_role_id?: string | null; + system_role?: string | null; } export interface InviteCreateBody { note?: string | null; expires_in_days?: number; + user_role_id?: string | null; } export interface InviteRedeemBody { diff --git a/dashboard/src/api/modules/userRoles.ts b/dashboard/src/api/modules/userRoles.ts new file mode 100644 index 0000000000..06e8be1c8c --- /dev/null +++ b/dashboard/src/api/modules/userRoles.ts @@ -0,0 +1,149 @@ +import type { TFunction } from "i18next"; +import { request, requestUpload } from "../request"; + +export interface RolePolicy { + name: string; + value: string; +} + +export interface UserRole { + user_role_id: string; + user_role_name: string; + description: string | null; + system_role: "admin" | "user"; + permissions: string[]; + policies: RolePolicy[]; + deletable: boolean; + immutable: boolean; + workspace_root_dir: string | null; + token_quota: number | null; + max_agents: number | null; + created_at: number; + updated_at: number; + avatar_url?: string | null; + avatar_icon?: string | null; +} + +export interface UserRoleWriteBody { + user_role_name: string; + description?: string | null; + permissions?: string[]; + policies?: RolePolicy[]; + avatar_icon?: string | null; +} + +export const SEEDED_ADMIN_ROLE_ID = "admin"; +export const SEEDED_USER_ROLE_ID = "user"; +const SEEDED_ADMIN_ROLE_NAME = "管理员"; +const SEEDED_USER_ROLE_NAME = "用户"; + +export const userRolesApi = { + list: () => request("/users/roles"), + + create: (body: UserRoleWriteBody) => + request("/users/roles", { + method: "POST", + body: JSON.stringify(body), + }), + + update: (userRoleId: string, body: Partial) => + request(`/users/roles/${encodeURIComponent(userRoleId)}`, { + method: "PATCH", + body: JSON.stringify(body), + }), + + remove: (userRoleId: string) => + request(`/users/roles/${encodeURIComponent(userRoleId)}`, { + method: "DELETE", + }), + + uploadAvatar: (userRoleId: string, file: File) => { + const body = new FormData(); + body.append("file", file); + return requestUpload<{ avatar_url: string | null }>( + `/users/roles/${encodeURIComponent(userRoleId)}/avatar`, + body, + ); + }, + + deleteAvatar: (userRoleId: string) => + request(`/users/roles/${encodeURIComponent(userRoleId)}/avatar`, { + method: "DELETE", + }), +}; + +/** Seeded admin/user names stay translatable until an admin renames them. */ +export function userRoleLabel( + role: Pick, + t: TFunction, +): string { + if ( + role.user_role_id === SEEDED_ADMIN_ROLE_ID && + role.user_role_name === SEEDED_ADMIN_ROLE_NAME + ) { + return t("adminUsers.seedRoleAdmin"); + } + if ( + role.user_role_id === SEEDED_USER_ROLE_ID && + role.user_role_name === SEEDED_USER_ROLE_NAME + ) { + return t("adminUsers.seedRoleUser"); + } + return role.user_role_name; +} + +export function userRoleDescription(role: UserRole, t: TFunction): string { + if (role.description) return role.description; + if (role.user_role_id === SEEDED_ADMIN_ROLE_ID) { + return t("adminUsers.seedRoleAdminDescription"); + } + if (role.user_role_id === SEEDED_USER_ROLE_ID) { + return t("adminUsers.seedRoleUserDescription"); + } + return t("adminUsers.roleDescriptionEmpty"); +} + +export function snapshotRoleLabel( + name: string | null | undefined, + roles: UserRole[], + t: TFunction, + userRoleId?: string | null, +): string { + const trimmed = name?.trim(); + if (!trimmed) return ""; + const match = userRoleId + ? roles.find((role) => role.user_role_id === userRoleId) + : roles.find((role) => role.user_role_name === trimmed); + return match ? userRoleLabel(match, t) : trimmed; +} + +export type UserRoleDrift = "missing" | "changed"; + +export function userRoleDrift( + row: { + role_name?: string | null; + user_role_id?: string | null; + permissions?: string[]; + workspace_root_dir?: string | null; + token_quota?: number | null; + max_agents?: number | null; + }, + roles: UserRole[], +): UserRoleDrift | null { + const name = row.role_name?.trim(); + if (!name) return null; + const role = row.user_role_id + ? roles.find((item) => item.user_role_id === row.user_role_id) + : roles.find((item) => item.user_role_name === name); + if (!role) return "missing"; + if (role.system_role === "admin") return null; + const left = [...(row.permissions ?? [])].sort().join("\0"); + const right = [...role.permissions].sort().join("\0"); + if (left !== right) return "changed"; + const root = row.workspace_root_dir?.trim() || null; + const roleRoot = role.workspace_root_dir?.trim() || null; + if (root !== roleRoot) return "changed"; + if ((row.token_quota ?? null) !== (role.token_quota ?? null)) return "changed"; + if ((row.max_agents ?? null) !== (role.max_agents ?? null)) return "changed"; + return null; +} diff --git a/dashboard/src/components/AvatarDropdown.module.less b/dashboard/src/components/AvatarDropdown.module.less index 4a93a0aba6..9b602db7dd 100644 --- a/dashboard/src/components/AvatarDropdown.module.less +++ b/dashboard/src/components/AvatarDropdown.module.less @@ -115,6 +115,41 @@ } } +.accountAvatar { + width: 32px; + height: 32px; + border-radius: 10px; + overflow: hidden; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + img, + svg { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + + svg { + width: 18px; + height: 18px; + } +} + +.accountAvatarLarge { + width: 44px; + height: 44px; + border-radius: 14px; + + svg { + width: 22px; + height: 22px; + } +} + .triggerExpanded { display: flex; align-items: center; diff --git a/dashboard/src/components/AvatarDropdown.tsx b/dashboard/src/components/AvatarDropdown.tsx index 52aebaf4ec..f4347689c7 100644 --- a/dashboard/src/components/AvatarDropdown.tsx +++ b/dashboard/src/components/AvatarDropdown.tsx @@ -1,6 +1,5 @@ import { useState, useCallback, useEffect, type ReactNode } from "react"; import { - Avatar, Modal, Drawer, Form, @@ -48,6 +47,10 @@ import { userCan } from "../utils/permissions"; import feishuIcon from "../assets/channels/feishu.svg"; import dingtalkIcon from "../assets/channels/dingtalk.svg"; import wecomIcon from "../assets/channels/wecom.svg"; +import { + ProfileAvatar, + ProfileAvatarPicker, +} from "../pages/Admin/Users/ProfileAvatar"; import styles from "./AvatarDropdown.module.less"; const GITHUB_URL = "https://github.com/TencentCloud/Octop"; @@ -251,9 +254,6 @@ export default function AvatarDropdown({ role === "admin" ? t("account.roleAdmin") : t("account.roleUser"); const displayName = user?.display_name || user?.username || "—"; - const initials = (user?.display_name || user?.username || "?") - .charAt(0) - .toUpperCase(); /** Defer panel open so the account Popover / mobile sidebar can unmount first. */ const deferOpen = (open: () => void) => { @@ -317,17 +317,12 @@ export default function AvatarDropdown({ }, [onUserChange, t]); const avatar = ( - - {initials} - + ); const menuContent = ( @@ -459,16 +454,12 @@ export default function AvatarDropdown({ const settingsBody = (
- - {initials} - +
{displayName} @@ -487,6 +478,54 @@ export default function AvatarDropdown({
+
+
+

{t("account.avatar")}

+

{t("account.avatarHint")}

+
+ {user ? ( + { + try { + onUserChange?.(await authApi.setAvatarIcon(icon)); + } catch (err) { + message.error( + apiErrorMessage(err, t("experts.avatarUploadFailed"), t), + ); + throw err; + } + }} + onPick={async (file) => { + try { + const result = await authApi.uploadAvatar(file); + onUserChange?.({ ...user, avatar_url: result.avatar_url }); + } catch (err) { + message.error( + apiErrorMessage(err, t("experts.avatarUploadFailed"), t), + ); + throw err; + } + }} + onRemove={async () => { + try { + await authApi.deleteAvatar(); + onUserChange?.({ ...user, avatar_url: null }); + } catch (err) { + message.error( + apiErrorMessage(err, t("experts.avatarRemoveFailed"), t), + ); + throw err; + } + }} + /> + ) : null} +
+ + +

diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 12ad4d7952..094b16da92 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -171,6 +171,7 @@ "AVATAR_INVALID": "Avatar must be a PNG, JPEG, WebP, or GIF image.", "AVATAR_TOO_LARGE": "Avatar exceeds the 5 MB size limit.", "INVITE_INVALID": "This invite code is invalid.", + "INVITE_ROLE_MISSING": "The role on this invite was deleted or renamed, so the account cannot be created.", "INVITE_USED": "This invite code has already been used.", "INVITE_EXPIRED": "This invite code has expired.", "INVITE_REVOKED": "This invite code has been revoked.", @@ -3611,6 +3612,8 @@ "role": "Role", "roleAdmin": "Administrator", "roleUser": "User", + "avatar": "Avatar", + "avatarHint": "Pick a default icon, or upload your own image.", "displayName": "Display name", "displayNamePlaceholder": "Your display name", "displayNameHint": "The name shown across the console.", @@ -4869,7 +4872,7 @@ "expired": "Expired", "revoked": "Revoked" }, - "searchPlaceholder": "Search username, display name, or email", + "searchPlaceholder": "Search username, display name, email, or role", "loadFailed": "Load failed", "createSuccess": "User \"{{username}}\" created", "createFailed": "Create failed", @@ -4896,7 +4899,8 @@ "colEmail": "Email", "colAuth": "Sign-in methods", "colCreatedAt": "Created", - "colRole": "Role", + "colRole": "Account type", + "colRoleName": "Role", "colLoginLock": "Login lock", "loginLockActive": "Locked · {{minutes}} min left", "loginLockNone": "—", @@ -4908,7 +4912,7 @@ "roleUser": "User", "roleAdminHint": "Manage system settings, users, and all data", "roleUserHint": "Use their own experts and workspace; no admin console", - "roleLegendTitle": "Roles", + "roleLegendTitle": "Account type", "roleLegend": "Admins can manage system settings, users, and all data. Regular users only use their own experts and workspace, and cannot manage other users or change global settings.", "formUsername": "Username", "formPassword": "Password", @@ -4916,7 +4920,39 @@ "formDisplayName": "Display name (optional)", "formEmail": "Email (optional)", "formEmailInvalid": "Enter a valid email address", - "formRole": "Role", + "formRole": "Account type", + "formUserRole": "Role", + "formUserRoleHint": "Optional. A role fills permissions and resource policy only. Set the account type separately. Leaving the role empty keeps the current permissions and policy.", + "avatarSamples": "Sample icons", + "avatarDefault": "Default icon", + "avatarIconUser": "Member", + "avatarIconBusiness": "Business", + "avatarIconResearch": "Research", + "avatarIconMedical": "Medical", + "avatarIconLaptop": "Technical", + "avatarIconTeach": "Teaching", + "avatarIconDesign": "Design", + "avatarIconLearner": "Learner", + "avatarIconMember": "Member", + "avatarIconAward": "Award", + "avatarIconTerminal": "Terminal", + "avatarIconPen": "Pen", + "avatarIconChart": "Chart", + "avatarIconHeadset": "Headset", + "avatarIconWrench": "Wrench", + "avatarIconScale": "Scale", + "noEmail": "No email", + "formUserRolePlaceholder": "No role", + "formUserRoleStale": "Recorded role name: {{name}}. That template was deleted or renamed. Current permissions stay until you pick another role.", + "formRoleDiverged": "This account's permissions or resource policy differ from the role defaults. Choosing another role overwrites those fields in the form.", + "seedRoleAdmin": "Administrator", + "seedRoleUser": "User", + "seedRoleAdminDescription": "Full access with no resource-policy limits.", + "seedRoleUserDescription": "For regular users with the baseline settings permissions.", + "roleNameChanged": "Customized", + "roleNameMissing": "Role unavailable", + "roleApplyConfirm": "Overwrite the current settings with this role?", + "roleApplyConfirmHint": "Permissions and resource policy become this role's defaults. The account type stays as it is, and you can still change the filled values before saving.", "viewTable": "Table", "viewCard": "Cards", "you": "You", @@ -4926,7 +4962,39 @@ "statusDisabled": "Disabled", "ssoBadge": "SSO", "passwordBadge": "Password", - "tabLocal": "Local users", + "tabLocal": "User management", + "tabRoles": "Roles", + "roleLoadFailed": "Failed to load roles", + "roleCreate": "New role", + "roleCreateTitle": "New role", + "roleEditTitle": "Edit role {{name}}", + "roleCreateSuccess": "Role “{{name}}” created", + "roleCreateFailed": "Failed to create role", + "roleUpdateSuccess": "Role updated", + "roleUpdateFailed": "Failed to update role", + "roleDeleteSuccess": "Role deleted", + "roleDeleteFailed": "Failed to delete role", + "roleDeleteConfirm": "Delete role “{{name}}”? Existing users keep their permissions, policies, and role name.", + "roleDeleteBuiltin": "The administrator role cannot be deleted", + "roleEmpty": "No roles yet", + "roleBuiltinAdmin": "Built-in", + "roleBuiltinUser": "Preset", + "roleAdminLocked": "The administrator role has every permission and no resource limits. You can rename it. You cannot delete it or change its permissions or policies.", + "roleTemplateHint": "A role only supplies defaults for create, edit, and invite. Changing a role does not rewrite existing users.", + "roleCustomHint": "A custom role supplies permissions and resource policy. Set the account type on the user.", + "roleColName": "Role name", + "roleDescription": "Description", + "roleDescriptionPlaceholder": "Describe who this role is intended for", + "roleDescriptionEmpty": "No description", + "roleColAccount": "Default account type", + "roleColPolicy": "Resource policy", + "rolePolicyUnlimited": "Off", + "rolePolicyLimited": "On", + "roleNameRequired": "Enter a role name", + "inviteRole": "Role", + "inviteRoleRequired": "Select a role", + "inviteRoleHint": "Only the role name is stored. Permissions and resource policy are read from that role when the invite is redeemed. If the role was deleted or renamed, the invite cannot be redeemed.", + "inviteRoleLegacy": "No role", "tabFeishu": "Feishu Login", "tabWecom": "WeCom Login", "tabDingtalk": "DingTalk Login", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 51d5104850..4f46def599 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -171,6 +171,7 @@ "AVATAR_INVALID": "头像须为 PNG、JPEG、WebP 或 GIF 图片。", "AVATAR_TOO_LARGE": "头像超过 5 MB 大小限制。", "INVITE_INVALID": "邀请码无效。", + "INVITE_ROLE_MISSING": "邀请对应的角色已删除或改名,无法完成注册。", "INVITE_USED": "该邀请码已被使用。", "INVITE_EXPIRED": "该邀请码已过期。", "INVITE_REVOKED": "该邀请码已被撤销。", @@ -3608,6 +3609,8 @@ "role": "角色", "roleAdmin": "管理员", "roleUser": "普通用户", + "avatar": "头像", + "avatarHint": "选择一个默认图标,或上传自己的图片。", "displayName": "显示名称", "displayNamePlaceholder": "您的显示名称", "displayNameHint": "设置在界面中展示的名称。", @@ -5008,7 +5011,7 @@ "expired": "已过期", "revoked": "已撤销" }, - "searchPlaceholder": "搜索用户名、显示名称或邮箱", + "searchPlaceholder": "搜索用户名、显示名称、邮箱或角色名称", "loadFailed": "加载失败", "createSuccess": "用户「{{username}}」已创建", "createFailed": "创建失败", @@ -5035,7 +5038,8 @@ "colEmail": "邮箱", "colAuth": "登录方式", "colCreatedAt": "创建时间", - "colRole": "角色", + "colRole": "账号类型", + "colRoleName": "角色名称", "colLoginLock": "登录锁定", "loginLockActive": "锁定中 · 剩余 {{minutes}} 分钟", "loginLockNone": "—", @@ -5047,7 +5051,7 @@ "roleUser": "普通用户", "roleAdminHint": "管理系统设置、用户权限与全部数据", "roleUserHint": "使用自有专家与工作区,无管理后台权限", - "roleLegendTitle": "角色说明", + "roleLegendTitle": "账号类型", "roleLegend": "管理员可管理系统设置、用户与全部数据;普通用户仅使用自有专家与工作区,不能管理其他用户或修改全局配置。", "formUsername": "用户名", "formPassword": "密码", @@ -5055,7 +5059,39 @@ "formDisplayName": "显示名称(可选)", "formEmail": "邮箱(可选)", "formEmailInvalid": "请输入有效的邮箱地址", - "formRole": "角色", + "formRole": "账号类型", + "formUserRole": "角色", + "formUserRoleHint": "可选。选择后只填入权限和资源策略,保存前仍可改写。账号类型需要单独设置。不选角色时,当前权限和策略保持不变。", + "avatarSamples": "示例图标", + "avatarDefault": "默认图标", + "avatarIconUser": "成员", + "avatarIconBusiness": "商务", + "avatarIconResearch": "研究", + "avatarIconMedical": "医疗", + "avatarIconLaptop": "技术", + "avatarIconTeach": "教学", + "avatarIconDesign": "设计", + "avatarIconLearner": "学员", + "avatarIconMember": "成员", + "avatarIconAward": "奖章", + "avatarIconTerminal": "终端", + "avatarIconPen": "画笔", + "avatarIconChart": "图表", + "avatarIconHeadset": "耳麦", + "avatarIconWrench": "扳手", + "avatarIconScale": "天平", + "noEmail": "未填写邮箱", + "formUserRolePlaceholder": "不使用角色", + "formUserRoleStale": "当前记录的角色名称:{{name}}。该模板已删除或改名,现有权限保持不变,直到你改选其他角色。", + "formRoleDiverged": "当前账号的权限或资源策略与该角色默认值不一致。改选角色会覆盖表单里的这些值。", + "seedRoleAdmin": "管理员", + "seedRoleUser": "用户", + "seedRoleAdminDescription": "拥有完整权限且不受资源策略限制。", + "seedRoleUserDescription": "适用于普通用户,默认具备基础设置权限。", + "roleNameChanged": "已改写", + "roleNameMissing": "角色已失效", + "roleApplyConfirm": "用所选角色覆盖当前设置?", + "roleApplyConfirmHint": "权限和资源策略会改成该角色的默认值。账号类型保持不变,保存前仍可再改。", "viewTable": "表格", "viewCard": "卡片", "you": "你", @@ -5065,7 +5101,39 @@ "statusDisabled": "已禁用", "ssoBadge": "单点登录", "passwordBadge": "密码", - "tabLocal": "内置用户", + "tabLocal": "用户管理", + "tabRoles": "角色管理", + "roleLoadFailed": "加载角色失败", + "roleCreate": "新建角色", + "roleCreateTitle": "新建角色", + "roleEditTitle": "编辑角色 {{name}}", + "roleCreateSuccess": "角色「{{name}}」已创建", + "roleCreateFailed": "创建角色失败", + "roleUpdateSuccess": "角色已更新", + "roleUpdateFailed": "更新角色失败", + "roleDeleteSuccess": "角色已删除", + "roleDeleteFailed": "删除角色失败", + "roleDeleteConfirm": "删除角色「{{name}}」?已创建的用户仍保留原来的权限、策略和角色名称。", + "roleDeleteBuiltin": "管理员角色不能删除", + "roleEmpty": "暂无角色", + "roleBuiltinAdmin": "内置", + "roleBuiltinUser": "预置", + "roleAdminLocked": "管理员角色拥有全部权限,且不启用资源限制。可以修改名称,不能删除,也不能改权限或策略。", + "roleTemplateHint": "角色只提供创建、编辑和邀请时的默认值。修改角色不会改写已经创建的用户。", + "roleCustomHint": "自定义角色只提供权限和资源策略。账号类型在用户上单独设置。", + "roleColName": "角色名称", + "roleDescription": "角色描述", + "roleDescriptionPlaceholder": "说明该角色适用的人员或职责", + "roleDescriptionEmpty": "暂无描述", + "roleColAccount": "默认账号类型", + "roleColPolicy": "资源策略", + "rolePolicyUnlimited": "未启用", + "rolePolicyLimited": "已启用", + "roleNameRequired": "请输入角色名称", + "inviteRole": "角色", + "inviteRoleRequired": "请选择角色", + "inviteRoleHint": "只记录角色名称。对方注册时读取该角色当前的权限和资源策略。角色若已删除或改名,邀请将无法完成注册。", + "inviteRoleLegacy": "未指定角色", "tabFeishu": "飞书登录", "tabWecom": "企微登录", "tabDingtalk": "钉钉登录", diff --git a/dashboard/src/pages/Admin/Users/InviteDrawer.tsx b/dashboard/src/pages/Admin/Users/InviteDrawer.tsx index 4e756fe066..7851d64a07 100644 --- a/dashboard/src/pages/Admin/Users/InviteDrawer.tsx +++ b/dashboard/src/pages/Admin/Users/InviteDrawer.tsx @@ -6,6 +6,7 @@ import { Form, Input, InputNumber, + Select, Pagination, Popconfirm, Space, @@ -15,6 +16,14 @@ import { } from "antd"; import { Copy, Link2, Plus, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { + SEEDED_USER_ROLE_ID, + snapshotRoleLabel, + userRoleLabel, + userRolesApi, + type UserRole, +} from "../../../api/modules/userRoles"; +import { RoleSelectLabel } from "./ProfileAvatar"; import { invitesApi, localInviteUrl, @@ -24,6 +33,7 @@ import { import { message } from "@/utils/antdMessage"; import { apiErrorMessage } from "../../../utils/apiError"; import { copyText } from "../../../utils/copyText"; +import { useCurrentUser } from "../../../hooks/useCurrentUser"; import { useServerTimezone } from "../../../hooks/useServerTimezone"; import { formatServerDateTime } from "../../../utils/formatMessageTime"; import styles from "./index.module.less"; @@ -44,14 +54,28 @@ interface InviteDrawerProps { onClose: () => void; } +function presetInviteRoleId(loaded: UserRole[], actorIsAdmin: boolean): string | undefined { + const pool = actorIsAdmin + ? loaded + : loaded.filter((role) => role.system_role !== "admin"); + return pool.find((role) => role.user_role_id === SEEDED_USER_ROLE_ID)?.user_role_id; +} + export default function InviteDrawer({ open, onClose }: InviteDrawerProps) { const { t } = useTranslation(); const timeZone = useServerTimezone(); + const currentUser = useCurrentUser(); + const actorIsAdmin = currentUser?.role === "admin"; const [loading, setLoading] = useState(false); const [creating, setCreating] = useState(false); const [rows, setRows] = useState([]); const [page, setPage] = useState(1); - const [form] = Form.useForm<{ note?: string; expires_in_days: number }>(); + const [roles, setRoles] = useState([]); + const [form] = Form.useForm<{ + note?: string; + expires_in_days: number; + user_role_id?: string; + }>(); const refresh = useCallback(async () => { setLoading(true); @@ -66,10 +90,24 @@ export default function InviteDrawer({ open, onClose }: InviteDrawerProps) { useEffect(() => { if (!open) return; - form.setFieldsValue({ expires_in_days: 7, note: undefined }); setPage(1); void refresh(); - }, [open, form, refresh]); + void userRolesApi + .list() + .then((loaded) => { + setRoles(loaded); + form.setFieldsValue({ + expires_in_days: 7, + note: undefined, + user_role_id: presetInviteRoleId(loaded, actorIsAdmin), + }); + }) + .catch((err) => { + setRoles([]); + form.setFieldsValue({ expires_in_days: 7, note: undefined }); + message.error(apiErrorMessage(err, t("adminUsers.roleLoadFailed"), t)); + }); + }, [actorIsAdmin, open, form, refresh, t]); const pageCount = Math.max(1, Math.ceil(rows.length / PAGE_SIZE) || 1); const safePage = Math.min(page, pageCount); @@ -85,15 +123,21 @@ export default function InviteDrawer({ open, onClose }: InviteDrawerProps) { const onCreate = async (values: { note?: string; expires_in_days: number; + user_role_id?: string; }) => { setCreating(true); try { const row = await invitesApi.create({ note: values.note?.trim() || null, expires_in_days: values.expires_in_days, + user_role_id: values.user_role_id, }); message.success(t("adminUsers.inviteCreateSuccess")); - form.setFieldsValue({ note: undefined, expires_in_days: 7 }); + form.setFieldsValue({ + note: undefined, + expires_in_days: 7, + user_role_id: presetInviteRoleId(roles, actorIsAdmin), + }); setRows((prev) => [row, ...prev.filter((r) => r.id !== row.id)]); setPage(1); const url = localInviteUrl(row.code); @@ -151,6 +195,51 @@ export default function InviteDrawer({ open, onClose }: InviteDrawerProps) { initialValues={{ expires_in_days: 7 }} className={styles.inviteCreateForm} > + + } /> + + + + + {locked ? ( +
+ {t("adminUsers.roleAdminLocked")} +
+ ) : ( + <> +
+ {t("adminUsers.roleCustomHint")} +
+ + + + + + )} + + + + ); +} diff --git a/dashboard/src/pages/Admin/Users/UsersListPanel.tsx b/dashboard/src/pages/Admin/Users/UsersListPanel.tsx index 5de5e1a787..f778b5194b 100644 --- a/dashboard/src/pages/Admin/Users/UsersListPanel.tsx +++ b/dashboard/src/pages/Admin/Users/UsersListPanel.tsx @@ -16,7 +16,7 @@ * DELETE /api/users/{id} */ -import { useEffect, useMemo, useState, useCallback } from "react"; +import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import { Button, Modal, @@ -32,6 +32,7 @@ import { Spin, Tag, Segmented, + Select, Checkbox, InputNumber, } from "antd"; @@ -41,9 +42,7 @@ import { ResizableTable } from "@/components/ResizableTable"; import { Bot, Check, - ChevronRight, CircleHelp, - Clock, Coins, IdCard, KeyRound, @@ -67,10 +66,19 @@ import { } from "lucide-react"; import { useTranslation } from "react-i18next"; import { request } from "../../../api/request"; +import { + userRolesApi, + snapshotRoleLabel, + userRoleDrift, + userRoleLabel, + type UserRole, +} from "../../../api/modules/userRoles"; import { authApi } from "../../../api/modules/auth"; import { useCardTableView } from "../../../hooks/useCardTableView"; +import { useSetCurrentUser } from "../../../hooks/useCurrentUser"; import { useIsMobile } from "../../../hooks/useIsMobile"; import { useServerTimezone } from "../../../hooks/useServerTimezone"; +import { apiErrorMessage } from "../../../utils/apiError"; import { formatServerDateTime } from "../../../utils/formatMessageTime"; import type { OctopAgent } from "../../../context/AgentContext"; import { AgentCard } from "../../Experts/components/AgentCard"; @@ -81,6 +89,13 @@ import { fetchFilesystemDefaults } from "../../Experts/components/agentBackendFo import { HOST_FS_ROOT } from "../../Experts/components/rootDirTree"; import expertStyles from "../../Experts/index.module.less"; import styles from "./index.module.less"; +import { + deleteUserAvatar, + ProfileAvatar, + ProfileAvatarPicker, + RoleSelectLabel, + uploadUserAvatar, +} from "./ProfileAvatar"; const { Text } = Typography; @@ -99,12 +114,16 @@ interface UserRow { login_retry_after_seconds?: number; created_at?: number; permissions?: string[]; + role_name?: string | null; + user_role_id?: string | null; + avatar_url?: string | null; + avatar_icon?: string | null; workspace_root_dir?: string | null; token_quota?: number | null; max_agents?: number | null; } -interface PermissionCatalogItem { +export interface PermissionCatalogItem { key: string; category: string; label: string; @@ -112,12 +131,12 @@ interface PermissionCatalogItem { page_label?: string; } -function permFullLabel(item: PermissionCatalogItem): string { +export function permFullLabel(item: PermissionCatalogItem): string { if (item.page_label) return `${item.page_label} / ${item.label}`; return item.label; } -interface PolicyFormValues { +export interface PolicyFormValues { limit_workspace_root?: boolean; workspace_root_dir?: string; limit_token_quota?: boolean; @@ -134,6 +153,8 @@ interface CreateValues extends PolicyFormValues { confirm: string; role: "admin" | "user"; permissions?: string[]; + user_role_id?: string | null; + role_name?: string | null; } interface EditValues extends PolicyFormValues { @@ -141,6 +162,8 @@ interface EditValues extends PolicyFormValues { email?: string; role: "admin" | "user"; permissions?: string[]; + user_role_id?: string | null; + role_name?: string | null; } interface ResetValues { @@ -148,6 +171,195 @@ interface ResetValues { confirm: string; } +function policyFieldsFromRole( + role: UserRole, + workspaceRootAllowed: boolean, +): PolicyFormValues { + return { + limit_workspace_root: + workspaceRootAllowed && Boolean(role.workspace_root_dir), + workspace_root_dir: role.workspace_root_dir ?? undefined, + limit_token_quota: role.token_quota != null, + token_quota: role.token_quota ?? undefined, + limit_max_agents: role.max_agents != null, + max_agents: role.max_agents ?? undefined, + }; +} + +function UserRoleField({ + roles, + workspaceRootAllowed, + staleName, + confirmOverwrite, + actorIsAdmin, + diverged, + snapshotName, +}: { + roles: UserRole[]; + workspaceRootAllowed: boolean; + staleName?: string | null; + confirmOverwrite?: boolean; + actorIsAdmin?: boolean; + diverged?: boolean; + snapshotName?: string | null; +}) { + const { t } = useTranslation(); + const form = Form.useFormInstance(); + const selectedId = Form.useWatch("user_role_id", form); + const showStale = Boolean(staleName) && selectedId == null; + const selectedRole = roles.find((role) => role.user_role_id === selectedId); + const showDiverged = + Boolean(diverged) && + Boolean(snapshotName) && + selectedRole?.user_role_name === snapshotName; + const visibleRoles = + actorIsAdmin === false + ? roles.filter((role) => role.system_role !== "admin") + : roles; + const committedId = useRef( + (form.getFieldValue("user_role_id") as string | null | undefined) ?? null, + ); + const committedName = useRef( + (form.getFieldValue("role_name") as string | null | undefined) ?? null, + ); + + const applyRole = (role: UserRole) => { + const next: Record = { + user_role_id: role.user_role_id, + role_name: role.user_role_name, + }; + if (role.system_role !== "admin") { + next.permissions = [...role.permissions]; + Object.assign(next, policyFieldsFromRole(role, workspaceRootAllowed)); + } + form.setFieldsValue(next); + committedId.current = role.user_role_id; + committedName.current = role.user_role_name; + }; + + return ( + <> + + + + edit( + renameGroup( + draft, + group.id, + event.target.value ? event.target.value : null, + ), + ) + } + /> + edit(deleteGroup(draft, group.id))} + okText={t("common.confirm")} + cancelText={t("common.cancel")} + > +

+
+ ); + })} +
+ +
+
{t("nav.hiddenItems")}
+
+ + ); +} + +function ItemList({ + zone, + keys, + items, + hidden, + t, + onDragStart, + onDropItem, + onMove, +}: { + zone: SidebarNavItemZone; + keys: string[]; + items: Map; + hidden: boolean; + t: (key: string) => string; + onDragStart: (event: DragEvent, payload: DragPayload) => void; + onDropItem: (zone: SidebarNavItemZone, beforeKey: string | null) => void; + onMove: (key: string, zone: SidebarNavItemZone) => void; +}) { + return ( +
{ + event.preventDefault(); + onDropItem(zone, null); + }} + > + {keys.length === 0 ? ( +
+ {hidden ? t("nav.hiddenEmpty") : t("nav.dropHere")} +
+ ) : null} + {keys.map((key) => { + const item = items.get(key); + if (!item) return null; + return ( +
{ + event.preventDefault(); + event.stopPropagation(); + onDropItem(zone, key); + }} + > + { + event.stopPropagation(); + onDragStart(event, { type: "item", key }); + }} + > + + + {item.icon} + {t(item.labelKey)} +
+ ); + })} +
+ ); +} diff --git a/dashboard/src/layouts/sidebarNav.test.ts b/dashboard/src/layouts/sidebarNav.test.ts index 4e6983fac2..b5bb4012b1 100644 --- a/dashboard/src/layouts/sidebarNav.test.ts +++ b/dashboard/src/layouts/sidebarNav.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { SIDEBAR_GROUPED_NAV_KEYS, + SIDEBAR_NAV_KEYS, buildNavSections, isGroupedNavKey, } from "./sidebarNav"; @@ -42,4 +43,19 @@ describe("sidebarNav", () => { expect(isGroupedNavKey(key)).toBe(false); } }); + + it("covers every catalog key from the admin nav", () => { + const keys = buildNavSections(adminUser, { mobileEnabled: true }).flatMap( + (section) => section.items.map((item) => item.key), + ); + expect(new Set(keys)).toEqual(new Set(SIDEBAR_NAV_KEYS)); + const grouped = buildNavSections(adminUser, { mobileEnabled: true }).filter( + (section) => section.id, + ); + expect(grouped.map((section) => section.id)).toEqual([ + "settings", + "control", + "admin", + ]); + }); }); diff --git a/dashboard/src/layouts/sidebarNav.tsx b/dashboard/src/layouts/sidebarNav.tsx index b3f8be5dcd..f7cc38dd35 100644 --- a/dashboard/src/layouts/sidebarNav.tsx +++ b/dashboard/src/layouts/sidebarNav.tsx @@ -37,11 +37,68 @@ export interface NavItem { } export interface NavSection { - /** When omitted, items render flat without a group header. */ + /** Stable group id. Omitted for the ungrouped block at the top. */ + id?: string; + /** Built-in label key. Omitted when {@link title} is set or the block is ungrouped. */ groupKey?: string; + /** User-defined group title. Wins over {@link groupKey}. */ + title?: string; items: NavItem[]; } +/** Every nav item key the sidebar can show. Keep in sync with `SIDEBAR_NAV_KEYS` in `octop.infra.users.preferences`. */ +export const SIDEBAR_NAV_KEYS = [ + "chat", + "experts", + "tasks", + "token-usage", + "personalization", + "channels", + "connectors", + "skill-packages", + "knowledge-bases", + "workbench", + "remote-desktop", + "acp", + "admin-users", + "models", + "admin-storage", + "admin-plugins", + "admin-security", + "admin-advanced", +] as const; + +export const BUILTIN_NAV_GROUP_IDS = ["settings", "control", "admin"] as const; + +const BUILTIN_NAV_GROUP_LABEL_KEYS: Record< + (typeof BUILTIN_NAV_GROUP_IDS)[number], + string +> = { + settings: "nav.settings", + control: "nav.control", + admin: "nav.admin", +}; + +export function isBuiltinNavGroupId(id: string): boolean { + return (BUILTIN_NAV_GROUP_IDS as readonly string[]).includes(id); +} + +export function builtinNavGroupLabelKey(id: string): string | null { + if (!isBuiltinNavGroupId(id)) return null; + return BUILTIN_NAV_GROUP_LABEL_KEYS[ + id as (typeof BUILTIN_NAV_GROUP_IDS)[number] + ]; +} + +export function navSectionLabel( + section: NavSection, + translate: (key: string) => string, +): string { + if (section.title) return section.title; + if (section.groupKey) return translate(section.groupKey); + return ""; +} + /** * Catalog of nav item keys that live under settings / control / admin groups. * Permission-independent — used for pane/route helpers; visibility still @@ -147,7 +204,11 @@ export function buildNavSections( }); } if (settingsItems.length > 0) { - sections.push({ groupKey: "nav.settings", items: settingsItems }); + sections.push({ + id: "settings", + groupKey: "nav.settings", + items: settingsItems, + }); } const controlItems: NavItem[] = []; @@ -181,7 +242,11 @@ export function buildNavSections( }); } if (controlItems.length > 0) { - sections.push({ groupKey: "nav.control", items: controlItems }); + sections.push({ + id: "control", + groupKey: "nav.control", + items: controlItems, + }); } const adminItems: NavItem[] = []; @@ -234,7 +299,7 @@ export function buildNavSections( }); } if (adminItems.length > 0) { - sections.push({ groupKey: "nav.admin", items: adminItems }); + sections.push({ id: "admin", groupKey: "nav.admin", items: adminItems }); } return sections; } diff --git a/dashboard/src/layouts/sidebarNavLayout.test.ts b/dashboard/src/layouts/sidebarNavLayout.test.ts new file mode 100644 index 0000000000..0a05499f83 --- /dev/null +++ b/dashboard/src/layouts/sidebarNavLayout.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import type { OctopUser } from "../api/modules/auth"; +import { buildNavSections } from "./sidebarNav"; +import { + createGroup, + deleteGroup, + editorFromCatalog, + layoutFromEditor, + placeItem, + sectionsFromLayout, + visibleEditorGroups, +} from "./sidebarNavLayout"; + +const adminUser = { + id: 1, + username: "admin", + role: "admin", + permissions: ["*"], +} as OctopUser; + +const limitedUser = { + id: 2, + username: "ada", + role: "user", + permissions: [], +} as OctopUser; + +function catalog() { + return buildNavSections(adminUser, { mobileEnabled: true }); +} + +describe("sidebarNavLayout", () => { + it("keeps the default catalog when no layout is saved", () => { + const sections = catalog(); + expect(sectionsFromLayout(sections, null)).toBe(sections); + const editor = editorFromCatalog(sections, null); + expect(editor.ungrouped).toEqual([ + "chat", + "experts", + "tasks", + "token-usage", + ]); + expect(editor.groups.map((group) => group.id)).toEqual([ + "settings", + "control", + "admin", + ]); + expect(editor.hidden).toEqual([]); + }); + + it("omits empty groups and hidden items from the sidebar", () => { + const sections = catalog(); + const applied = sectionsFromLayout(sections, { + groups: [ + { id: "settings", name: "配置" }, + { id: "control" }, + { id: "c_emptygrp1", name: "空组" }, + ], + items: [ + { key: "chat" }, + { key: "personalization", group: "settings" }, + { key: "token-usage", hidden: true }, + { key: "workbench", group: "control" }, + ], + }); + expect(applied.map((section) => section.id ?? "front")).toEqual([ + "front", + "settings", + "control", + ]); + expect(applied[0]?.items.map((item) => item.key)).toEqual(["chat"]); + expect(applied[1]?.title).toBe("配置"); + expect(applied[1]?.items.map((item) => item.key)).toEqual([ + "personalization", + ]); + expect(applied[2]?.groupKey).toBe("nav.control"); + }); + + it("sends keys missing from a saved layout to hidden", () => { + const editor = editorFromCatalog(catalog(), { + groups: [{ id: "settings" }], + items: [{ key: "chat" }], + }); + expect(editor.ungrouped).toEqual(["chat"]); + expect(editor.hidden).toContain("experts"); + expect(editor.hidden).toContain("personalization"); + expect( + sectionsFromLayout(catalog(), { + groups: [{ id: "settings" }], + items: [{ key: "chat" }], + }) + .flatMap((section) => section.items.map((item) => item.key)) + .includes("experts"), + ).toBe(false); + }); + + it("moves a deleted group's items to the front of the ungrouped list", () => { + const editor = editorFromCatalog(catalog(), null); + const next = deleteGroup(editor, "settings"); + expect(next.groups.map((group) => group.id)).toEqual(["control", "admin"]); + expect(next.ungrouped.slice(0, 2)).toEqual(["personalization", "channels"]); + expect(next.ungrouped).toContain("chat"); + }); + + it("places an item before another and can hide it", () => { + const editor = editorFromCatalog(catalog(), null); + const moved = placeItem(editor, "tasks", { kind: "ungrouped" }, "chat"); + expect(moved.ungrouped.slice(0, 2)).toEqual(["tasks", "chat"]); + const hidden = placeItem(moved, "tasks", { kind: "hidden" }, null); + expect(hidden.ungrouped).not.toContain("tasks"); + expect(hidden.hidden).toContain("tasks"); + const layout = layoutFromEditor(hidden); + expect(layout.items.find((item) => item.key === "tasks")?.hidden).toBe( + true, + ); + }); + + it("omits nav items and built-in groups the user cannot access", () => { + const sections = buildNavSections(limitedUser, { mobileEnabled: false }); + const editor = editorFromCatalog(sections, { + groups: [ + { id: "settings" }, + { id: "admin" }, + { id: "c_aabbccdd", name: "常用" }, + ], + items: [ + { key: "chat" }, + { key: "admin-users", group: "admin" }, + { key: "channels", group: "settings" }, + { key: "personalization", group: "settings" }, + ], + }); + const shown = [ + ...editor.ungrouped, + ...Object.values(editor.itemsByGroup).flat(), + ...editor.hidden, + ]; + expect(shown).not.toContain("admin-users"); + expect(shown).not.toContain("channels"); + expect(shown).toContain("chat"); + expect(shown).toContain("personalization"); + expect( + visibleEditorGroups(editor, sections).map((group) => group.id), + ).toEqual(["settings", "c_aabbccdd"]); + }); + + it("keeps a new empty group only in the editor", () => { + const editor = createGroup( + editorFromCatalog(catalog(), null), + "c_aabbccdd", + "常用", + ); + expect(editor.groups.at(-1)?.name).toBe("常用"); + const applied = sectionsFromLayout(catalog(), layoutFromEditor(editor)); + expect(applied.some((section) => section.id === "c_aabbccdd")).toBe(false); + }); +}); diff --git a/dashboard/src/layouts/sidebarNavLayout.ts b/dashboard/src/layouts/sidebarNavLayout.ts new file mode 100644 index 0000000000..22d2fadf48 --- /dev/null +++ b/dashboard/src/layouts/sidebarNavLayout.ts @@ -0,0 +1,359 @@ +import type { NavItem, NavSection } from "./sidebarNav"; +import { + SIDEBAR_NAV_KEYS, + builtinNavGroupLabelKey, + isBuiltinNavGroupId, +} from "./sidebarNav"; + +export interface SidebarNavGroup { + id: string; + /** Empty when a built-in group still uses its translated label. */ + name?: string | null; +} + +export interface SidebarNavItemPlacement { + key: string; + group?: string | null; + hidden?: boolean; +} + +export interface SidebarNavLayout { + groups: SidebarNavGroup[]; + items: SidebarNavItemPlacement[]; +} + +export interface SidebarNavEditorState { + groups: SidebarNavGroup[]; + ungrouped: string[]; + itemsByGroup: Record; + hidden: string[]; +} + +export type SidebarNavItemZone = + | { kind: "ungrouped" } + | { kind: "hidden" } + | { kind: "group"; groupId: string }; + +const BUILTIN_ID_BY_LABEL: Record = { + "nav.settings": "settings", + "nav.control": "control", + "nav.admin": "admin", +}; + +const NAV_KEY_SET = new Set(SIDEBAR_NAV_KEYS); + +export function catalogItems(sections: NavSection[]): Map { + const items = new Map(); + for (const section of sections) { + for (const item of section.items) items.set(item.key, item); + } + return items; +} + +function defaultEditor(sections: NavSection[]): SidebarNavEditorState { + const ungrouped: string[] = []; + const groups: SidebarNavGroup[] = []; + const itemsByGroup: Record = {}; + for (const section of sections) { + const keys = section.items.map((item) => item.key); + if (!section.id && !section.groupKey && !section.title) { + ungrouped.push(...keys); + continue; + } + const id = + section.id ?? + (section.groupKey ? BUILTIN_ID_BY_LABEL[section.groupKey] : undefined) ?? + section.groupKey ?? + "group"; + groups.push({ id, name: section.title ?? null }); + itemsByGroup[id] = keys; + } + return { groups, ungrouped, itemsByGroup, hidden: [] }; +} + +function defaultKeyOrder(state: SidebarNavEditorState): string[] { + return [ + ...state.ungrouped, + ...state.groups.flatMap((group) => state.itemsByGroup[group.id] ?? []), + ]; +} + +/** Editor model for the drawer. A missing layout is the system default. */ +export function editorFromCatalog( + sections: NavSection[], + layout: SidebarNavLayout | null, +): SidebarNavEditorState { + const base = defaultEditor(sections); + if (!layout) return base; + + const catalogKeys = new Set(defaultKeyOrder(base)); + const groups: SidebarNavGroup[] = []; + const groupIds = new Set(); + for (const group of layout.groups) { + if (groupIds.has(group.id)) continue; + groupIds.add(group.id); + groups.push({ id: group.id, name: group.name ?? null }); + } + const itemsByGroup: Record = {}; + for (const group of groups) itemsByGroup[group.id] = []; + + const ungrouped: string[] = []; + const hidden: string[] = []; + const seen = new Set(); + for (const item of layout.items) { + if (!catalogKeys.has(item.key) || seen.has(item.key)) continue; + seen.add(item.key); + if (item.hidden) { + hidden.push(item.key); + continue; + } + if (item.group && groupIds.has(item.group)) { + itemsByGroup[item.group].push(item.key); + } else { + ungrouped.push(item.key); + } + } + for (const key of defaultKeyOrder(base)) { + if (!seen.has(key)) hidden.push(key); + } + return { groups, ungrouped, itemsByGroup, hidden }; +} + +/** + * Groups to show in the customizer. + * Built-in groups the user cannot access stay out. Empty custom groups stay, + * so items can be dragged back in. + */ +export function visibleEditorGroups( + state: SidebarNavEditorState, + sections: NavSection[], +): SidebarNavGroup[] { + const catalogGroupIds = new Set( + sections.flatMap((section) => (section.id ? [section.id] : [])), + ); + return state.groups.filter((group) => { + const count = state.itemsByGroup[group.id]?.length ?? 0; + if (count > 0) return true; + if (!isBuiltinNavGroupId(group.id)) return true; + return catalogGroupIds.has(group.id); + }); +} + +/** Keys stored for items the current user cannot see, so a later save does not drop them. */ +export function preservedPlacements( + layout: SidebarNavLayout | null, + sections: NavSection[], +): SidebarNavItemPlacement[] { + if (!layout) return []; + const visible = new Set(catalogItems(sections).keys()); + return layout.items.filter( + (item) => NAV_KEY_SET.has(item.key) && !visible.has(item.key), + ); +} + +export function layoutFromEditor( + state: SidebarNavEditorState, + preserved: SidebarNavItemPlacement[] = [], +): SidebarNavLayout { + const items: SidebarNavItemPlacement[] = []; + const seen = new Set(); + const push = (item: SidebarNavItemPlacement) => { + if (seen.has(item.key)) return; + seen.add(item.key); + items.push(item); + }; + for (const key of state.ungrouped) push({ key }); + for (const group of state.groups) { + for (const key of state.itemsByGroup[group.id] ?? []) { + push({ key, group: group.id }); + } + } + for (const key of state.hidden) push({ key, hidden: true }); + for (const extra of preserved) push(extra); + return { + groups: state.groups.map((group) => { + const name = group.name?.trim() ?? ""; + return name ? { id: group.id, name } : { id: group.id }; + }), + items, + }; +} + +function sectionForGroup( + group: SidebarNavGroup, + items: NavItem[], +): NavSection | null { + if (items.length === 0) return null; + const custom = group.name?.trim() ?? ""; + if (custom) return { id: group.id, title: custom, items }; + const labelKey = builtinNavGroupLabelKey(group.id); + if (labelKey) return { id: group.id, groupKey: labelKey, items }; + return null; +} + +/** Sidebar sections. Empty groups are omitted. `null` layout keeps the catalog. */ +export function sectionsFromLayout( + sections: NavSection[], + layout: SidebarNavLayout | null, +): NavSection[] { + if (!layout) return sections; + const byKey = catalogItems(sections); + const editor = editorFromCatalog(sections, layout); + const result: NavSection[] = []; + const ungrouped = editor.ungrouped + .map((key) => byKey.get(key)) + .filter((item): item is NavItem => Boolean(item)); + if (ungrouped.length > 0) result.push({ items: ungrouped }); + for (const group of editor.groups) { + const items = (editor.itemsByGroup[group.id] ?? []) + .map((key) => byKey.get(key)) + .filter((item): item is NavItem => Boolean(item)); + const section = sectionForGroup(group, items); + if (section) result.push(section); + } + return result; +} + +function stripKey( + state: SidebarNavEditorState, + key: string, +): SidebarNavEditorState { + const itemsByGroup: Record = {}; + for (const [id, keys] of Object.entries(state.itemsByGroup)) { + itemsByGroup[id] = keys.filter((item) => item !== key); + } + return { + ...state, + ungrouped: state.ungrouped.filter((item) => item !== key), + hidden: state.hidden.filter((item) => item !== key), + itemsByGroup, + }; +} + +function zoneList( + state: SidebarNavEditorState, + zone: SidebarNavItemZone, +): string[] { + if (zone.kind === "ungrouped") return state.ungrouped; + if (zone.kind === "hidden") return state.hidden; + return state.itemsByGroup[zone.groupId] ?? []; +} + +function withZoneList( + state: SidebarNavEditorState, + zone: SidebarNavItemZone, + list: string[], +): SidebarNavEditorState { + if (zone.kind === "ungrouped") return { ...state, ungrouped: list }; + if (zone.kind === "hidden") return { ...state, hidden: list }; + return { + ...state, + itemsByGroup: { ...state.itemsByGroup, [zone.groupId]: list }, + }; +} + +/** Insert `key` before `beforeKey`. `beforeKey` null appends. */ +export function placeItem( + state: SidebarNavEditorState, + key: string, + zone: SidebarNavItemZone, + beforeKey: string | null, +): SidebarNavEditorState { + if (beforeKey === key) return state; + if ( + zone.kind === "group" && + !state.groups.some((group) => group.id === zone.groupId) + ) { + return state; + } + const stripped = stripKey(state, key); + const list = [...zoneList(stripped, zone)]; + const index = beforeKey ? list.indexOf(beforeKey) : list.length; + list.splice(index < 0 ? list.length : index, 0, key); + return withZoneList(stripped, zone, list); +} + +export function moveGroup( + state: SidebarNavEditorState, + groupId: string, + beforeId: string | null, +): SidebarNavEditorState { + if (beforeId === groupId) return state; + const moving = state.groups.find((group) => group.id === groupId); + if (!moving) return state; + const groups = state.groups.filter((group) => group.id !== groupId); + const index = beforeId + ? groups.findIndex((group) => group.id === beforeId) + : groups.length; + groups.splice(index < 0 ? groups.length : index, 0, moving); + return { ...state, groups }; +} + +export function createGroup( + state: SidebarNavEditorState, + id: string, + name: string, +): SidebarNavEditorState { + return { + ...state, + groups: [...state.groups, { id, name }], + itemsByGroup: { ...state.itemsByGroup, [id]: [] }, + }; +} + +export function renameGroup( + state: SidebarNavEditorState, + id: string, + name: string | null, +): SidebarNavEditorState { + return { + ...state, + groups: state.groups.map((group) => + group.id === id ? { ...group, name } : group, + ), + }; +} + +/** Drop the group. Its items become ungrouped and move to the front. */ +export function deleteGroup( + state: SidebarNavEditorState, + groupId: string, +): SidebarNavEditorState { + const released = state.itemsByGroup[groupId] ?? []; + const itemsByGroup = { ...state.itemsByGroup }; + delete itemsByGroup[groupId]; + return { + ...state, + groups: state.groups.filter((group) => group.id !== groupId), + ungrouped: [...released, ...state.ungrouped], + itemsByGroup, + }; +} + +export function nextGroupName( + state: SidebarNavEditorState, + base: string, +): string { + const names = new Set( + state.groups.map((group) => (group.name ?? "").trim()).filter(Boolean), + ); + if (!names.has(base)) return base; + let n = 2; + while (names.has(`${base} ${n}`)) n += 1; + return `${base} ${n}`; +} + +export function newNavGroupId(): string { + const bytes = crypto.getRandomValues(new Uint8Array(8)); + const body = Array.from(bytes, (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + return `c_${body}`; +} + +export function groupNameMissing(state: SidebarNavEditorState): boolean { + return state.groups.some((group) => { + if (isBuiltinNavGroupId(group.id)) return false; + return !(group.name ?? "").trim(); + }); +} diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index d0406dfed3..3d617fdac0 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -247,7 +247,26 @@ "remoteAndroid": "Remote Phone", "records": "Tasks", "paneSettings": "Settings", - "paneRecords": "Tasks" + "paneRecords": "Tasks", + "customize": "Customize navigation", + "customizeTitle": "Customize navigation", + "ungrouped": "Ungrouped", + "ungroupedHint": "Shown at the top of the sidebar, without a heading.", + "hiddenItems": "Hidden", + "hiddenEmpty": "Nothing hidden", + "newGroup": "New group", + "newGroupDefault": "New group", + "deleteGroup": "Delete group", + "deleteGroupConfirm": "Items in this group move to the front and are no longer grouped.", + "resetLayout": "Reset", + "resetLayoutConfirm": "Restore the default navigation? Current groups and hidden items will be cleared.", + "dropHere": "Drop items here", + "showInNav": "Show in sidebar", + "hideFromNav": "Hide from sidebar", + "groupNamePlaceholder": "Group name", + "groupNameRequired": "Enter a group name", + "saveFailed": "Could not save the navigation layout", + "dragToReorder": "Drag to reorder" }, "skillPackages": { "title": "Skill Packages", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index aad03a2a05..c20bf3fe23 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -247,7 +247,26 @@ "remoteAndroid": "远程手机", "records": "任务", "paneSettings": "设置", - "paneRecords": "任务" + "paneRecords": "任务", + "customize": "自定义导航", + "customizeTitle": "自定义导航", + "ungrouped": "未分组", + "ungroupedHint": "显示在侧栏最前面,没有分组标题。", + "hiddenItems": "未显示", + "hiddenEmpty": "没有隐藏的条目", + "newGroup": "新建分组", + "newGroupDefault": "新分组", + "deleteGroup": "删除分组", + "deleteGroupConfirm": "删除后,其中的条目会排到最前面,不再属于分组。", + "resetLayout": "恢复默认", + "resetLayoutConfirm": "恢复为系统默认导航?当前的分组和隐藏设置会被清掉。", + "dropHere": "把条目拖到这里", + "showInNav": "显示在侧栏", + "hideFromNav": "从侧栏隐藏", + "groupNamePlaceholder": "分组名称", + "groupNameRequired": "请填写分组名称", + "saveFailed": "没能保存导航布局", + "dragToReorder": "拖动排序" }, "skillPackages": { "title": "技能包", diff --git a/src/octop/api/routers/preferences.py b/src/octop/api/routers/preferences.py index cec040f6d0..e5e551016d 100644 --- a/src/octop/api/routers/preferences.py +++ b/src/octop/api/routers/preferences.py @@ -18,6 +18,8 @@ get_model_reasoning_from_json, get_preferred_model_from_json, get_remote_browser_bookmarks_from_json, + get_sidebar_nav_from_json, + merge_sidebar_nav_json, parse_preferences_json, ) from octop.infra.utils.locale import normalize_locale @@ -35,6 +37,25 @@ class ModelReasoningPreferenceModel(BaseModel): effort: str | None = None +class SidebarNavGroupModel(BaseModel): + id: str = Field(description="Built-in id (`settings`, `control`, `admin`) or `c_` + hex.") + name: str | None = Field( + default=None, + description="Custom title. Omit to keep a built-in group's translated label.", + ) + + +class SidebarNavItemModel(BaseModel): + key: str = Field(description="Nav item key.") + group: str | None = Field(default=None, description="Group id. Omit when ungrouped or hidden.") + hidden: bool = Field(default=False, description="When true, the item stays out of the sidebar.") + + +class SidebarNavLayoutModel(BaseModel): + groups: list[SidebarNavGroupModel] = Field(default_factory=list) + items: list[SidebarNavItemModel] = Field(default_factory=list) + + class PreferencesResponse(BaseModel): locale: str = Field(description="UI locale: `zh` or `en`.") remote_browser_bookmarks: list[RemoteBrowserBookmarkModel] = Field( @@ -50,6 +71,10 @@ class PreferencesResponse(BaseModel): description="Per-model reasoning defaults for this user.", ) timezone: str | None = Field(default=None, description="Preferred IANA timezone.") + sidebar_nav: SidebarNavLayoutModel | None = Field( + default=None, + description="Custom sidebar layout. Null means the user has not customized navigation.", + ) class PatchPreferencesBody(BaseModel): @@ -61,11 +86,22 @@ class PatchPreferencesBody(BaseModel): preferred_model: str | None = None model_reasoning: dict[str, ModelReasoningPreferenceModel] | None = None timezone: str | None = None + sidebar_nav: SidebarNavLayoutModel | None = Field( + default=None, + description="Replace the sidebar layout. Null clears it and restores the default nav.", + ) @model_validator(mode="after") def at_least_one_field(self) -> Self: if not self.model_fields_set.intersection( - {"locale", "remote_browser_bookmarks", "preferred_model", "model_reasoning", "timezone"} + { + "locale", + "remote_browser_bookmarks", + "preferred_model", + "model_reasoning", + "timezone", + "sidebar_nav", + } ): raise ValueError("at least one preference field is required") if ( @@ -97,9 +133,17 @@ def _response(row: Any) -> PreferencesResponse: for ref, pref in get_model_reasoning_from_json(raw).items() }, timezone=parse_preferences_json(raw).get(PREFERENCES_KEY_TIMEZONE), + sidebar_nav=_sidebar_nav_response(raw), ) +def _sidebar_nav_response(raw: str | None) -> SidebarNavLayoutModel | None: + payload = get_sidebar_nav_from_json(raw) + if payload is None: + return None + return SidebarNavLayoutModel.model_validate(payload) + + @router.get("/preferences", summary="Current user preferences", response_model=PreferencesResponse) async def get_preferences( user: Any = Depends(current_user), @@ -162,5 +206,10 @@ async def patch_preferences( else: data.pop(PREFERENCES_KEY_TIMEZONE, None) server.services.user_repo.set_preferences_json(user.id, json.dumps(data)) + if "sidebar_nav" in body.model_fields_set: + row = server.services.user_repo.get(user.id) + layout = None if body.sidebar_nav is None else body.sidebar_nav.model_dump() + merged = merge_sidebar_nav_json(row.preferences_json if row else None, layout) + server.services.user_repo.set_preferences_json(user.id, merged) row = server.services.user_repo.get(user.id) return _response(row) diff --git a/src/octop/infra/users/preferences.py b/src/octop/infra/users/preferences.py index 2725d8360d..78376cebf6 100644 --- a/src/octop/infra/users/preferences.py +++ b/src/octop/infra/users/preferences.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import re from dataclasses import dataclass from typing import Any from urllib.parse import urlparse @@ -15,6 +16,35 @@ PREFERENCES_KEY_PREFERRED_MODEL = "preferred_model" PREFERENCES_KEY_MODEL_REASONING = "model_reasoning" PREFERENCES_KEY_TIMEZONE = "timezone" +PREFERENCES_KEY_SIDEBAR_NAV = "sidebar_nav" + +# Keep in sync with dashboard/src/layouts/sidebarNav.tsx SIDEBAR_NAV_KEYS. +SIDEBAR_NAV_KEYS = frozenset( + { + "chat", + "experts", + "tasks", + "token-usage", + "personalization", + "channels", + "connectors", + "skill-packages", + "knowledge-bases", + "workbench", + "remote-desktop", + "acp", + "admin-users", + "models", + "admin-storage", + "admin-plugins", + "admin-security", + "admin-advanced", + } +) +_BUILTIN_SIDEBAR_GROUP_IDS = frozenset({"settings", "control", "admin"}) +_CUSTOM_SIDEBAR_GROUP_ID = re.compile(r"^c_[a-z0-9]{8,32}$") +_MAX_SIDEBAR_GROUPS = 24 +_MAX_SIDEBAR_GROUP_NAME = 40 MAX_BOOKMARK_TITLE_LEN = 80 REASONING_MODES = frozenset({"auto", "enabled", "disabled"}) @@ -32,6 +62,19 @@ class RemoteBrowserBookmark: title: str +@dataclass(frozen=True) +class SidebarNavGroup: + id: str + name: str | None = None + + +@dataclass(frozen=True) +class SidebarNavItem: + key: str + group: str | None = None + hidden: bool = False + + def parse_preferences_json(raw: str | None) -> dict[str, Any]: if not raw or not raw.strip(): return {} @@ -152,3 +195,117 @@ def merge_model_preferences_json( ref: {"mode": pref.mode, "effort": pref.effort} for ref, pref in model_reasoning.items() } return json.dumps(data, ensure_ascii=False, separators=(",", ":")) + + +def _sidebar_group_name(value: Any, *, builtin: bool) -> str | None: + if value is None: + if builtin: + return None + raise OctopError(ErrorCode.SLASH_BAD_ARGS, "sidebar nav group name is required") + if not isinstance(value, str): + raise OctopError(ErrorCode.SLASH_BAD_ARGS, "invalid sidebar nav group name") + name = value.strip() + if not name: + if builtin: + return None + raise OctopError(ErrorCode.SLASH_BAD_ARGS, "sidebar nav group name is required") + if len(name) > _MAX_SIDEBAR_GROUP_NAME or any(ch in name for ch in "\n\r\t"): + raise OctopError(ErrorCode.SLASH_BAD_ARGS, "invalid sidebar nav group name") + return name + + +def validate_sidebar_nav(value: Any) -> tuple[list[SidebarNavGroup], list[SidebarNavItem]]: + """Normalize a sidebar layout. Unknown keys and dangling groups are rejected.""" + if not isinstance(value, dict): + raise OctopError(ErrorCode.SLASH_BAD_ARGS, "invalid sidebar nav layout") + raw_groups = value.get("groups", []) + raw_items = value.get("items", []) + if not isinstance(raw_groups, list) or not isinstance(raw_items, list): + raise OctopError(ErrorCode.SLASH_BAD_ARGS, "invalid sidebar nav layout") + if len(raw_groups) > _MAX_SIDEBAR_GROUPS: + raise OctopError( + ErrorCode.SLASH_BAD_ARGS, + f"sidebar nav groups limit is {_MAX_SIDEBAR_GROUPS}", + ) + groups: list[SidebarNavGroup] = [] + seen_groups: set[str] = set() + for raw in raw_groups: + if not isinstance(raw, dict): + raise OctopError(ErrorCode.SLASH_BAD_ARGS, "invalid sidebar nav group") + group_id = str(raw.get("id") or "").strip() + builtin = group_id in _BUILTIN_SIDEBAR_GROUP_IDS + if not builtin and _CUSTOM_SIDEBAR_GROUP_ID.fullmatch(group_id) is None: + raise OctopError(ErrorCode.SLASH_BAD_ARGS, "invalid sidebar nav group id") + if group_id in seen_groups: + raise OctopError(ErrorCode.SLASH_BAD_ARGS, "duplicate sidebar nav group") + seen_groups.add(group_id) + groups.append( + SidebarNavGroup( + id=group_id, + name=_sidebar_group_name(raw.get("name"), builtin=builtin), + ) + ) + items: list[SidebarNavItem] = [] + seen_keys: set[str] = set() + for raw in raw_items: + if not isinstance(raw, dict): + raise OctopError(ErrorCode.SLASH_BAD_ARGS, "invalid sidebar nav item") + key = str(raw.get("key") or "").strip() + if key not in SIDEBAR_NAV_KEYS: + raise OctopError(ErrorCode.SLASH_BAD_ARGS, "unknown sidebar nav item") + if key in seen_keys: + raise OctopError(ErrorCode.SLASH_BAD_ARGS, "duplicate sidebar nav item") + seen_keys.add(key) + hidden = bool(raw.get("hidden")) + raw_group = raw.get("group") + item_group: str | None = ( + str(raw_group).strip() if isinstance(raw_group, str) and raw_group.strip() else None + ) + if hidden: + item_group = None + elif item_group is not None and item_group not in seen_groups: + raise OctopError(ErrorCode.SLASH_BAD_ARGS, "sidebar nav item group does not exist") + items.append(SidebarNavItem(key=key, group=item_group, hidden=hidden)) + return groups, items + + +def sidebar_nav_to_dict( + groups: list[SidebarNavGroup], + items: list[SidebarNavItem], +) -> dict[str, Any]: + payload_items: list[dict[str, Any]] = [] + for item in items: + row: dict[str, Any] = {"key": item.key} + if item.group: + row["group"] = item.group + if item.hidden: + row["hidden"] = True + payload_items.append(row) + payload_groups: list[dict[str, Any]] = [] + for group in groups: + row = {"id": group.id} + if group.name: + row["name"] = group.name + payload_groups.append(row) + return {"groups": payload_groups, "items": payload_items} + + +def get_sidebar_nav_from_json(raw: str | None) -> dict[str, Any] | None: + value = parse_preferences_json(raw).get(PREFERENCES_KEY_SIDEBAR_NAV) + if value is None: + return None + try: + groups, items = validate_sidebar_nav(value) + except OctopError: + return None + return sidebar_nav_to_dict(groups, items) + + +def merge_sidebar_nav_json(current_raw: str | None, layout: Any | None) -> str: + data = parse_preferences_json(current_raw) + if layout is None: + data.pop(PREFERENCES_KEY_SIDEBAR_NAV, None) + else: + groups, items = validate_sidebar_nav(layout) + data[PREFERENCES_KEY_SIDEBAR_NAV] = sidebar_nav_to_dict(groups, items) + return json.dumps(data, ensure_ascii=False, separators=(",", ":")) diff --git a/tests/integration/test_preferences_api.py b/tests/integration/test_preferences_api.py index 8b07793c25..c33a2e3ce4 100644 --- a/tests/integration/test_preferences_api.py +++ b/tests/integration/test_preferences_api.py @@ -86,3 +86,33 @@ async def test_preferences_timezone_rejects_invalid_value(env) -> None: client, _srv, auth = env r = await client.patch("/api/preferences", headers=auth, json={"timezone": "BAD_ZONE"}) assert r.status_code in (400, 422) + + +@pytest.mark.asyncio +async def test_preferences_sidebar_nav_roundtrip(env) -> None: + client, _srv, auth = env + r = await client.get("/api/preferences", headers=auth) + assert r.status_code == 200 + assert r.json()["sidebar_nav"] is None + + payload = { + "sidebar_nav": { + "groups": [{"id": "settings"}, {"id": "c_aabbccdd1122", "name": "常用"}], + "items": [ + {"key": "chat"}, + {"key": "experts", "group": "c_aabbccdd1122"}, + {"key": "tasks", "hidden": True}, + ], + } + } + r = await client.patch("/api/preferences", headers=auth, json=payload) + assert r.status_code == 200 + saved = r.json()["sidebar_nav"] + assert saved["groups"][1]["name"] == "常用" + assert saved["items"][1]["group"] == "c_aabbccdd1122" + assert saved["items"][2]["key"] == "tasks" + assert saved["items"][2]["hidden"] is True + + r = await client.patch("/api/preferences", headers=auth, json={"sidebar_nav": None}) + assert r.status_code == 200 + assert r.json()["sidebar_nav"] is None diff --git a/tests/unit/users/test_preferences.py b/tests/unit/users/test_preferences.py index cd43a2d005..794361bf04 100644 --- a/tests/unit/users/test_preferences.py +++ b/tests/unit/users/test_preferences.py @@ -10,10 +10,13 @@ get_model_reasoning_from_json, get_preferred_model_from_json, get_remote_browser_bookmarks_from_json, + get_sidebar_nav_from_json, get_timezone_from_preferences_json, merge_model_preferences_json, merge_preferences_json, + merge_sidebar_nav_json, validate_remote_browser_bookmarks, + validate_sidebar_nav, ) @@ -102,6 +105,46 @@ def test_model_preferences_roundtrip_and_preserve_other_keys() -> None: ) +def test_sidebar_nav_roundtrip_preserves_other_keys() -> None: + layout = { + "groups": [ + {"id": "settings", "name": "配置"}, + {"id": "c_aabbccdd11223344", "name": "常用"}, + ], + "items": [ + {"key": "chat"}, + {"key": "personalization", "group": "settings"}, + {"key": "tasks", "hidden": True, "group": "settings"}, + ], + } + merged = merge_sidebar_nav_json('{"foo": 1}', layout) + saved = get_sidebar_nav_from_json(merged) + assert saved is not None + assert saved["groups"][0] == {"id": "settings", "name": "配置"} + assert saved["groups"][1]["id"] == "c_aabbccdd11223344" + hidden = next(item for item in saved["items"] if item["key"] == "tasks") + assert hidden == {"key": "tasks", "hidden": True} + assert '"foo": 1' in merged or '"foo":1' in merged + + +def test_sidebar_nav_rejects_unknown_item_and_dangling_group() -> None: + with pytest.raises(OctopError) as ei: + validate_sidebar_nav({"groups": [], "items": [{"key": "not-a-nav"}]}) + assert ei.value.code is ErrorCode.SLASH_BAD_ARGS + with pytest.raises(OctopError): + validate_sidebar_nav({"groups": [], "items": [{"key": "chat", "group": "settings"}]}) + with pytest.raises(OctopError): + validate_sidebar_nav({"groups": [{"id": "c_short", "name": "X"}], "items": []}) + + +def test_sidebar_nav_clear_and_invalid_payload() -> None: + merged = merge_sidebar_nav_json("{}", {"groups": [], "items": [{"key": "chat"}]}) + cleared = merge_sidebar_nav_json(merged, None) + assert get_sidebar_nav_from_json(cleared) is None + assert get_sidebar_nav_from_json('{"sidebar_nav": "nope"}') is None + assert get_sidebar_nav_from_json("{}") is None + + def test_clear_preferred_model_keeps_reasoning_defaults() -> None: raw = merge_model_preferences_json( "{}", From 51c937fbd410eb70e91741e290cd2b1957c76200 Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Sun, 27 Sep 2026 18:36:14 +0800 Subject: [PATCH 042/126] style(dashboard): apply Prettier to files that drifted from the formatter (#1222) Co-authored-by: jubaoliang Co-authored-by: Cursor --- dashboard/src/api/modules/auth.ts | 5 +- dashboard/src/api/modules/userRoles.ts | 3 +- .../src/pages/Admin/Users/InviteDrawer.tsx | 158 ++++++------ .../src/pages/Admin/Users/ProfileAvatar.tsx | 140 ++++++++-- .../src/pages/Admin/Users/RolesPanel.tsx | 241 ++++++++++-------- .../src/pages/Admin/Users/UsersListPanel.tsx | 105 ++++---- .../AssistantProcessSummary.test.tsx | 4 +- 7 files changed, 399 insertions(+), 257 deletions(-) diff --git a/dashboard/src/api/modules/auth.ts b/dashboard/src/api/modules/auth.ts index 870f362242..cb0a66ebcf 100644 --- a/dashboard/src/api/modules/auth.ts +++ b/dashboard/src/api/modules/auth.ts @@ -274,7 +274,10 @@ export const authApi = { uploadAvatar: (file: File) => { const body = new FormData(); body.append("file", file); - return requestUpload<{ avatar_url: string | null }>("/auth/me/avatar", body); + return requestUpload<{ avatar_url: string | null }>( + "/auth/me/avatar", + body, + ); }, deleteAvatar: () => request("/auth/me/avatar", { method: "DELETE" }), diff --git a/dashboard/src/api/modules/userRoles.ts b/dashboard/src/api/modules/userRoles.ts index 06e8be1c8c..baa84c33b7 100644 --- a/dashboard/src/api/modules/userRoles.ts +++ b/dashboard/src/api/modules/userRoles.ts @@ -143,7 +143,8 @@ export function userRoleDrift( const root = row.workspace_root_dir?.trim() || null; const roleRoot = role.workspace_root_dir?.trim() || null; if (root !== roleRoot) return "changed"; - if ((row.token_quota ?? null) !== (role.token_quota ?? null)) return "changed"; + if ((row.token_quota ?? null) !== (role.token_quota ?? null)) + return "changed"; if ((row.max_agents ?? null) !== (role.max_agents ?? null)) return "changed"; return null; } diff --git a/dashboard/src/pages/Admin/Users/InviteDrawer.tsx b/dashboard/src/pages/Admin/Users/InviteDrawer.tsx index 7851d64a07..4bb7b10b85 100644 --- a/dashboard/src/pages/Admin/Users/InviteDrawer.tsx +++ b/dashboard/src/pages/Admin/Users/InviteDrawer.tsx @@ -54,11 +54,15 @@ interface InviteDrawerProps { onClose: () => void; } -function presetInviteRoleId(loaded: UserRole[], actorIsAdmin: boolean): string | undefined { +function presetInviteRoleId( + loaded: UserRole[], + actorIsAdmin: boolean, +): string | undefined { const pool = actorIsAdmin ? loaded : loaded.filter((role) => role.system_role !== "admin"); - return pool.find((role) => role.user_role_id === SEEDED_USER_ROLE_ID)?.user_role_id; + return pool.find((role) => role.user_role_id === SEEDED_USER_ROLE_ID) + ?.user_role_id; } export default function InviteDrawer({ open, onClose }: InviteDrawerProps) { @@ -297,84 +301,84 @@ export default function InviteDrawer({ open, onClose }: InviteDrawerProps) { (!row.user_role_id && role.user_role_name === row.role_name), ); return ( -
-
- - - {t(`adminUsers.inviteStatus.${row.status}`)} - - {row.code} - - -
+
+ + + {localInviteUrl(row.code)} + +
+ + {row.role_name?.trim() ? ( + + ) : ( + t("adminUsers.inviteRoleLegacy") + )} + {row.role_name?.trim() && !matchedRole ? ( + <> + {" "} + {t("adminUsers.roleNameMissing")} + ) : null} - -
-
- - - {localInviteUrl(row.code)} - -
- - {row.role_name?.trim() ? ( - - ) : ( - t("adminUsers.inviteRoleLegacy") - )} - {row.role_name?.trim() && !matchedRole ? ( - <> - {" "} - {t("adminUsers.roleNameMissing")} - - ) : null} - {row.note ? ` · ${row.note}` : ""} - -
- - {t("adminUsers.inviteCreatedAt", { - time: formatServerDateTime(row.created_at, timeZone), - })} - - - {t("adminUsers.inviteExpiresAt", { - time: formatServerDateTime(row.expires_at, timeZone), - })} + {row.note ? ` · ${row.note}` : ""} +
+ + {t("adminUsers.inviteCreatedAt", { + time: formatServerDateTime(row.created_at, timeZone), + })} + + + {t("adminUsers.inviteExpiresAt", { + time: formatServerDateTime(row.expires_at, timeZone), + })} + +
-
); })}
diff --git a/dashboard/src/pages/Admin/Users/ProfileAvatar.tsx b/dashboard/src/pages/Admin/Users/ProfileAvatar.tsx index 8b2a2e910a..4fa70e4443 100644 --- a/dashboard/src/pages/Admin/Users/ProfileAvatar.tsx +++ b/dashboard/src/pages/Admin/Users/ProfileAvatar.tsx @@ -40,25 +40,121 @@ interface AvatarPreset { } const USER_PRESETS: AvatarPreset[] = [ - { id: "user", labelKey: "adminUsers.avatarIconUser", bg: "#e8f1ff", fg: "#175cd3", Icon: UserRound }, - { id: "business", labelKey: "adminUsers.avatarIconBusiness", bg: "#fff4d6", fg: "#b54708", Icon: Briefcase }, - { id: "research", labelKey: "adminUsers.avatarIconResearch", bg: "#f4ebff", fg: "#6941c6", Icon: FlaskConical }, - { id: "medical", labelKey: "adminUsers.avatarIconMedical", bg: "#ffe4e8", fg: "#c01048", Icon: Stethoscope }, - { id: "laptop", labelKey: "adminUsers.avatarIconLaptop", bg: "#e0f2fe", fg: "#026aa2", Icon: Laptop }, - { id: "teach", labelKey: "adminUsers.avatarIconTeach", bg: "#f3e8dd", fg: "#93370d", Icon: BookOpen }, - { id: "design", labelKey: "adminUsers.avatarIconDesign", bg: "#fce7f6", fg: "#c11574", Icon: Palette }, - { id: "learner", labelKey: "adminUsers.avatarIconLearner", bg: "#fef7c3", fg: "#a15c07", Icon: GraduationCap }, + { + id: "user", + labelKey: "adminUsers.avatarIconUser", + bg: "#e8f1ff", + fg: "#175cd3", + Icon: UserRound, + }, + { + id: "business", + labelKey: "adminUsers.avatarIconBusiness", + bg: "#fff4d6", + fg: "#b54708", + Icon: Briefcase, + }, + { + id: "research", + labelKey: "adminUsers.avatarIconResearch", + bg: "#f4ebff", + fg: "#6941c6", + Icon: FlaskConical, + }, + { + id: "medical", + labelKey: "adminUsers.avatarIconMedical", + bg: "#ffe4e8", + fg: "#c01048", + Icon: Stethoscope, + }, + { + id: "laptop", + labelKey: "adminUsers.avatarIconLaptop", + bg: "#e0f2fe", + fg: "#026aa2", + Icon: Laptop, + }, + { + id: "teach", + labelKey: "adminUsers.avatarIconTeach", + bg: "#f3e8dd", + fg: "#93370d", + Icon: BookOpen, + }, + { + id: "design", + labelKey: "adminUsers.avatarIconDesign", + bg: "#fce7f6", + fg: "#c11574", + Icon: Palette, + }, + { + id: "learner", + labelKey: "adminUsers.avatarIconLearner", + bg: "#fef7c3", + fg: "#a15c07", + Icon: GraduationCap, + }, ]; const ROLE_PRESETS: AvatarPreset[] = [ - { id: "member", labelKey: "adminUsers.avatarIconMember", bg: "", fg: "", Icon: SquareUser }, - { id: "award", labelKey: "adminUsers.avatarIconAward", bg: "", fg: "", Icon: Award }, - { id: "terminal", labelKey: "adminUsers.avatarIconTerminal", bg: "", fg: "", Icon: Terminal }, - { id: "pen", labelKey: "adminUsers.avatarIconPen", bg: "", fg: "", Icon: PenLine }, - { id: "chart", labelKey: "adminUsers.avatarIconChart", bg: "", fg: "", Icon: ChartColumn }, - { id: "headset", labelKey: "adminUsers.avatarIconHeadset", bg: "", fg: "", Icon: Headset }, - { id: "wrench", labelKey: "adminUsers.avatarIconWrench", bg: "", fg: "", Icon: Wrench }, - { id: "scale", labelKey: "adminUsers.avatarIconScale", bg: "", fg: "", Icon: Scale }, + { + id: "member", + labelKey: "adminUsers.avatarIconMember", + bg: "", + fg: "", + Icon: SquareUser, + }, + { + id: "award", + labelKey: "adminUsers.avatarIconAward", + bg: "", + fg: "", + Icon: Award, + }, + { + id: "terminal", + labelKey: "adminUsers.avatarIconTerminal", + bg: "", + fg: "", + Icon: Terminal, + }, + { + id: "pen", + labelKey: "adminUsers.avatarIconPen", + bg: "", + fg: "", + Icon: PenLine, + }, + { + id: "chart", + labelKey: "adminUsers.avatarIconChart", + bg: "", + fg: "", + Icon: ChartColumn, + }, + { + id: "headset", + labelKey: "adminUsers.avatarIconHeadset", + bg: "", + fg: "", + Icon: Headset, + }, + { + id: "wrench", + labelKey: "adminUsers.avatarIconWrench", + bg: "", + fg: "", + Icon: Wrench, + }, + { + id: "scale", + labelKey: "adminUsers.avatarIconScale", + bg: "", + fg: "", + Icon: Scale, + }, ]; function presetsFor(kind: AvatarKind): AvatarPreset[] { @@ -91,11 +187,9 @@ function pickImageFile(): Promise { const input = document.createElement("input"); input.type = "file"; input.accept = AVATAR_ACCEPT; - input.addEventListener( - "change", - () => resolve(input.files?.[0] ?? null), - { once: true }, - ); + input.addEventListener("change", () => resolve(input.files?.[0] ?? null), { + once: true, + }); input.click(); }); } @@ -236,7 +330,9 @@ export function ProfileAvatarPicker({ -
{t("adminUsers.avatarSamples")}
+
+ {t("adminUsers.avatarSamples")} +
{presets.map((preset, index) => { const selected = diff --git a/dashboard/src/pages/Admin/Users/RolesPanel.tsx b/dashboard/src/pages/Admin/Users/RolesPanel.tsx index 68ed35bfe6..c6ff1bd116 100644 --- a/dashboard/src/pages/Admin/Users/RolesPanel.tsx +++ b/dashboard/src/pages/Admin/Users/RolesPanel.tsx @@ -74,7 +74,9 @@ export default function RolesPanel() { const baselinePermissions = useMemo( () => - permCatalog.filter((item) => item.category === "settings").map((item) => item.key), + permCatalog + .filter((item) => item.category === "settings") + .map((item) => item.key), [permCatalog], ); @@ -148,7 +150,7 @@ export default function RolesPanel() { ? Boolean(editor.workspace_root_dir) : false, workspace_root_dir: workspaceRootAllowed - ? (editor.workspace_root_dir ?? undefined) + ? editor.workspace_root_dir ?? undefined : undefined, limit_token_quota: editor.token_quota != null, token_quota: editor.token_quota ?? undefined, @@ -205,7 +207,9 @@ export default function RolesPanel() { avatar_icon: pendingIcon, }); } - message.success(t("adminUsers.roleCreateSuccess", { name: values.name.trim() })); + message.success( + t("adminUsers.roleCreateSuccess", { name: values.name.trim() }), + ); } setPendingAvatar(null); setPendingIcon(null); @@ -215,7 +219,9 @@ export default function RolesPanel() { message.error( apiErrorMessage( err, - editing ? t("adminUsers.roleUpdateFailed") : t("adminUsers.roleCreateFailed"), + editing + ? t("adminUsers.roleUpdateFailed") + : t("adminUsers.roleCreateFailed"), t, ), ); @@ -244,7 +250,9 @@ export default function RolesPanel() { {t("adminUsers.tabRoles")} -

{t("adminUsers.roleTemplateHint")}

+

+ {t("adminUsers.roleTemplateHint")} +

) ) : ( - }} - columns={[ - { - title: t("adminUsers.roleColName"), - dataIndex: "user_role_name", - render: (_name: string, row) => ( - - - {userRoleLabel(row, t)} - {row.user_role_id === "admin" ? ( - {t("adminUsers.roleBuiltinAdmin")} - ) : null} - {row.user_role_id === "user" ? ( - {t("adminUsers.roleBuiltinUser")} - ) : null} - - ), - }, - { - title: t("adminUsers.roleDescription"), - dataIndex: "description", - ellipsis: true, - render: (_description: string | null, row) => - userRoleDescription(row, t), - }, - { - title: t("adminUsers.colPermissions"), - width: 160, - render: (_, row) => - row.system_role === "admin" - ? t("adminUsers.permAll") - : t("adminUsers.permCount", { count: row.permissions.length }), - }, - { - title: t("adminUsers.roleColPolicy"), - width: 140, - render: (_, row) => { - const limited = - Boolean(row.workspace_root_dir) || - row.token_quota != null || - row.max_agents != null; - return limited - ? t("adminUsers.rolePolicyLimited") - : t("adminUsers.rolePolicyUnlimited"); + , + }} + columns={[ + { + title: t("adminUsers.roleColName"), + dataIndex: "user_role_name", + render: (_name: string, row) => ( + + + {userRoleLabel(row, t)} + {row.user_role_id === "admin" ? ( + {t("adminUsers.roleBuiltinAdmin")} + ) : null} + {row.user_role_id === "user" ? ( + {t("adminUsers.roleBuiltinUser")} + ) : null} + + ), }, - }, - { - title: t("adminUsers.colActions"), - width: 100, - render: (_, row) => ( - - - - - void onDelete(row)} - disabled={!row.deletable} - > - + { + title: t("adminUsers.roleDescription"), + dataIndex: "description", + ellipsis: true, + render: (_description: string | null, row) => + userRoleDescription(row, t), + }, + { + title: t("adminUsers.colPermissions"), + width: 160, + render: (_, row) => + row.system_role === "admin" + ? t("adminUsers.permAll") + : t("adminUsers.permCount", { + count: row.permissions.length, + }), + }, + { + title: t("adminUsers.roleColPolicy"), + width: 140, + render: (_, row) => { + const limited = + Boolean(row.workspace_root_dir) || + row.token_quota != null || + row.max_agents != null; + return limited + ? t("adminUsers.rolePolicyLimited") + : t("adminUsers.rolePolicyUnlimited"); + }, + }, + { + title: t("adminUsers.colActions"), + width: 100, + render: (_, row) => ( + + - - - ), - }, - ]} - /> + void onDelete(row)} + disabled={!row.deletable} + > + + + + + + ), + }, + ]} + /> )} -
@@ -519,9 +537,12 @@ export default function RolesPanel() { return; } try { - const updated = await userRolesApi.update(editing.user_role_id, { - avatar_icon: icon, - }); + const updated = await userRolesApi.update( + editing.user_role_id, + { + avatar_icon: icon, + }, + ); setEditor({ ...editing, avatar_url: updated.avatar_url, @@ -563,7 +584,11 @@ export default function RolesPanel() { void refresh(); } catch (err) { message.error( - apiErrorMessage(err, t("experts.avatarRemoveFailed"), t), + apiErrorMessage( + err, + t("experts.avatarRemoveFailed"), + t, + ), ); throw err; } @@ -574,7 +599,9 @@ export default function RolesPanel() { } /> @@ -592,7 +619,9 @@ export default function RolesPanel() { ) : ( <> -
+
{t("adminUsers.roleCustomHint")}
} /> + {editing ? ( + + + {editing.user_role_id} + + + ) : null} role.user_role_id === selectedId); const showDiverged = Boolean(diverged) && @@ -217,7 +216,7 @@ function UserRoleField({ ? roles.filter((role) => role.system_role !== "admin") : roles; const committedId = useRef( - (form.getFieldValue("user_role_id") as string | null | undefined) ?? null, + (form.getFieldValue("role") as string | null | undefined) ?? null, ); const committedName = useRef( (form.getFieldValue("role_name") as string | null | undefined) ?? null, @@ -225,12 +224,14 @@ function UserRoleField({ const applyRole = (role: UserRole) => { const next: Record = { - user_role_id: role.user_role_id, + role: role.user_role_id, role_name: role.user_role_name, }; if (role.system_role !== "admin") { next.permissions = [...role.permissions]; Object.assign(next, policyFieldsFromRole(role, workspaceRootAllowed)); + } else { + next.permissions = []; } form.setFieldsValue(next); committedId.current = role.user_role_id; @@ -244,7 +245,10 @@ function UserRoleField({ + edit( + renameGroup( + draft, + group.id, + event.target.value ? event.target.value : null, + ), + ) + } + /> + edit(deleteGroup(draft, group.id))} + okText={t("common.confirm")} + cancelText={t("common.cancel")} + > +
+