diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e4849069..3c55bd09 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -96,6 +96,38 @@ jobs: retention-days: 7 path: packages/*/.artifacts/unit/junit.xml + desktop-smoke: + name: desktop-smoke (macos) + # The prevent-sleep smoke drives the real packaged main bundle through Playwright and asserts + # against pmset, so it needs a macOS runner with a window server; keep it out of the unit matrix. + runs-on: macos-14 + defaults: + run: + shell: bash + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "24" + + - name: Setup Bun + uses: ./.github/actions/setup-bun + + - name: Build desktop main bundle + working-directory: packages/desktop + run: bun run build + timeout-minutes: 15 + + - name: Run prevent-sleep smoke + working-directory: packages/desktop + run: bun run test:prevent-sleep-smoke + timeout-minutes: 10 + e2e: name: e2e (${{ matrix.settings.name }}) strategy: diff --git a/packages/app/src/components/dialog-custom-provider-form.ts b/packages/app/src/components/dialog-custom-provider-form.ts index cac792ea..ddd685e9 100644 --- a/packages/app/src/components/dialog-custom-provider-form.ts +++ b/packages/app/src/components/dialog-custom-provider-form.ts @@ -34,6 +34,16 @@ export type CustomProviderConfig = { const npmForProtocol = (kind: ProviderProtocol | undefined) => (kind === "anthropic" ? ANTHROPIC : OPENAI_COMPATIBLE) +export function isAmbiguousProtocolError(error: unknown) { + const message = + error instanceof Error + ? error.message + : error && typeof error === "object" && "message" in error && typeof error.message === "string" + ? error.message + : "" + return message.includes("Provider protocol is ambiguous") +} + // Leading host labels that are generic service prefixes and make a poor provider id, so we skip past // them to reach the brand label (api.deepseek.com -> "deepseek", not "api"). Kept deliberately small: // only unambiguous service prefixes, never anything that could be a brand. @@ -183,11 +193,14 @@ export function validateCustomProvider(input: ValidateArgs) { // Zero-config path: when the user leaves id/name blank we derive them from the URL, so those // fields are no longer required. Derivation needs a usable URL — if the URL itself is invalid we // skip it and let urlError drive the failure instead of emitting a spurious id/name error. - const derived = !urlError && (!typedID || !typedName) ? deriveProviderIdentity({ - baseURL, - existingProviderIDs: input.existingProviderIDs, - disabledProviders: input.disabledProviders, - }) : undefined + const derived = + !urlError && (!typedID || !typedName) + ? deriveProviderIdentity({ + baseURL, + existingProviderIDs: input.existingProviderIDs, + disabledProviders: input.disabledProviders, + }) + : undefined const providerID = typedID || derived?.providerID || "" const name = typedName || derived?.name || "" @@ -232,8 +245,7 @@ export function validateCustomProvider(input: ValidateArgs) { const contextError = ctx && !/^\d+$/.test(ctx) ? input.t("provider.custom.error.context") : undefined return { id: idError, name: nameError, context: contextError } }) - const modelsValid = - (discoveryMode || models.every((m) => !m.id && !m.name)) && models.every((m) => !m.context) + const modelsValid = (discoveryMode || models.every((m) => !m.id && !m.name)) && models.every((m) => !m.context) const modelConfig = Object.fromEntries( input.form.models.map((m) => { const ctx = m.context.trim() @@ -309,10 +321,9 @@ export function validateCustomProvider(input: ValidateArgs) { } } -// Build the dialog form state for editing an existing custom provider. Fields (URL/key/headers/name) -// come from the raw config entry; model rows are seeded from the RESOLVED provider so the user sees the -// actual context/reasoning/temperature values (a discovery provider has no models in config — its -// specs only exist post-resolve). Each row is pre-filled so edits override just those fields. +// Discovery results are runtime state, not durable config. In discovery mode only configured model +// overrides become editable rows; otherwise merely opening and saving the dialog would snapshot every +// cached model into config and keep revoked/removed models alive indefinitely. export function formStateFromProvider(input: { config: ProviderConfig resolved: ResolvedProvider | undefined @@ -321,20 +332,24 @@ export function formStateFromProvider(input: { const headers = config.options?.headers const headerRows = headers && typeof headers === "object" && Object.keys(headers).length - ? Object.entries(headers as Record).map(([key, value]) => - headerRow2(String(key), String(value)), - ) + ? Object.entries(headers as Record).map(([key, value]) => headerRow2(String(key), String(value))) : [headerRow()] - const resolvedModels = resolved?.models ?? {} - const modelRows = Object.entries(resolvedModels).map(([id, m]) => - modelRow({ - id, - name: m.name || id, - context: m.limit?.context ? String(m.limit.context) : "", - reasoning: !!m.capabilities?.reasoning, - temperature: !!m.capabilities?.temperature, - }), + const modelRows = Object.entries(config.discovery ? (config.models ?? {}) : (resolved?.models ?? {})).map( + ([id, configured]) => { + const model = resolved?.models[id] + return modelRow({ + id, + name: configured.name || model?.name || id, + context: configured.limit?.context + ? String(configured.limit.context) + : model?.limit?.context + ? String(model.limit.context) + : "", + reasoning: configured.reasoning ?? !!model?.capabilities?.reasoning, + temperature: configured.temperature ?? !!model?.capabilities?.temperature, + }) + }, ) return { diff --git a/packages/app/src/components/dialog-custom-provider.test.ts b/packages/app/src/components/dialog-custom-provider.test.ts index 4e0ff95e..4eb5805d 100644 --- a/packages/app/src/components/dialog-custom-provider.test.ts +++ b/packages/app/src/components/dialog-custom-provider.test.ts @@ -2,12 +2,20 @@ import { describe, expect, test } from "bun:test" import { deriveProviderIdentity, formStateFromProvider, + isAmbiguousProtocolError, modelRow, validateCustomProvider, } from "./dialog-custom-provider-form" const t = (key: string) => key +test("recognizes protocol ambiguity from SDK and Error response shapes", () => { + const message = "Provider protocol is ambiguous; select OpenAI-compatible or Anthropic explicitly" + expect(isAmbiguousProtocolError(new Error(message))).toBe(true) + expect(isAmbiguousProtocolError({ message })).toBe(true) + expect(isAmbiguousProtocolError(new Error("HTTP 404"))).toBe(false) +}) + describe("validateCustomProvider", () => { test("builds trimmed config payload", () => { const result = validateCustomProvider({ @@ -290,6 +298,7 @@ describe("formStateFromProvider", () => { npm: "@ai-sdk/openai-compatible", discovery: true, options: { baseURL: "https://relay.example.com", apiKey: "secret", headers: { "X-Env": "prod" } }, + models: { "gpt-4o": { name: "GPT-4o" } }, }, resolved: { id: "relay", @@ -337,6 +346,50 @@ describe("formStateFromProvider", () => { temperature: true, }) }) + + test("does not turn discovered runtime models into durable overrides", () => { + const form = formStateFromProvider({ + config: { + name: "My Relay", + npm: "@ai-sdk/openai-compatible", + discovery: true, + options: { baseURL: "https://relay.example.com", apiKey: "secret" }, + }, + resolved: { + id: "relay", + name: "My Relay", + source: "custom", + env: [], + options: {}, + models: { + stale: { + id: "stale", + providerID: "relay", + api: { id: "stale", url: "", npm: "@ai-sdk/openai-compatible" }, + name: "Stale", + capabilities: { + temperature: false, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 0, output: 0 }, + status: "active", + options: {}, + headers: {}, + release_date: "", + }, + }, + }, + }) + + expect(form.models).toHaveLength(1) + expect(form.models[0]).toMatchObject({ id: "", name: "" }) + }) }) describe("deriveProviderIdentity", () => { diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 1b4a0054..952846d4 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -18,6 +18,7 @@ import { deriveProviderIdentity, formStateFromProvider, headerRow, + isAmbiguousProtocolError, modelRow, validateCustomProvider, } from "./dialog-custom-provider-form" @@ -244,8 +245,10 @@ export function DialogCustomProvider(props: Props) { const choice = protocolChoice() // Defensive client-side timeout: the backend already caps its /models fetch, but guard the // whole round-trip too so a stalled request can never leave the submit button hung. On - // timeout (or any error) we fall through to manual/validation handling instead of blocking. - const res = await Promise.race([ + // A timeout or ordinary discovery failure falls through to manual validation. Protocol + // ambiguity is different: continuing would either guess the wrong SDK or fail with no useful + // feedback, so require an explicit protocol choice. + const discovery = await Promise.race([ serverSDK.client.provider.models .discover( { @@ -259,7 +262,18 @@ export function DialogCustomProvider(props: Props) { ) .then((res) => res.data), new Promise((resolve) => setTimeout(() => resolve(undefined), 20_000)), - ]).catch(() => undefined) + ]).then( + (data) => ({ data, error: undefined }), + (error: unknown) => ({ data: undefined, error }), + ) + if (choice === "auto" && isAmbiguousProtocolError(discovery.error)) { + showToast({ + title: language.t("common.requestFailed"), + description: language.t("provider.custom.error.protocol.ambiguous"), + }) + return + } + const res = discovery.data if (res?.kind) setDetectedProtocol(res.kind) const discovered = res?.models ?? [] if (discovered.length > 0 && !hasManualModels) { diff --git a/packages/app/src/components/provider-model-refresh.test.ts b/packages/app/src/components/provider-model-refresh.test.ts index 20fb6526..588b0e4e 100644 --- a/packages/app/src/components/provider-model-refresh.test.ts +++ b/packages/app/src/components/provider-model-refresh.test.ts @@ -5,6 +5,11 @@ describe("provider model refresh", () => { test("allows official, discovery, and legacy imported providers", () => { expect(canRefreshProviderModels("openai", undefined)).toBe(true) expect(canRefreshProviderModels("custom", { discovery: true })).toBe(true) + expect( + canRefreshProviderModels("grouped", { + groups: { anthropic: { npm: "@ai-sdk/anthropic", discovery: true } }, + }), + ).toBe(true) expect( canRefreshProviderModels("mistral", { options: { baseURL: "https://api.mistral.ai/v1" }, diff --git a/packages/app/src/components/provider-model-refresh.ts b/packages/app/src/components/provider-model-refresh.ts index 90786742..a7b81ae7 100644 --- a/packages/app/src/components/provider-model-refresh.ts +++ b/packages/app/src/components/provider-model-refresh.ts @@ -4,6 +4,7 @@ import type { ProviderConfig } from "@deepagent-code/sdk/v2" export function canRefreshProviderModels(providerID: string, config: ProviderConfig | undefined) { if (isOfficialProvider(providerID)) return true if (config?.discovery === true) return true + if (Object.values(config?.groups ?? {}).some((group) => group.discovery === true)) return true return ( config?.npm === undefined && typeof config?.options?.baseURL === "string" && diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 4594c5fc..e447a6c4 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -132,6 +132,12 @@ export const SettingsGeneralV2: Component = () => { { initialValue: false }, ) + const [preventSleep, { mutate: setPreventSleep }] = createResource( + () => (desktop() && platform.getPreventSleepEnabled ? true : false), + () => Promise.resolve(platform.getPreventSleepEnabled?.() ?? true).catch(() => true), + { initialValue: true }, + ) + onMount(() => { void theme.loadThemes() }) @@ -252,6 +258,13 @@ export const SettingsGeneralV2: Component = () => { void update.catch(() => setPinchZoom(!checked)) } + const onPreventSleepChange = (checked: boolean) => { + setPreventSleep(checked) + const update = platform.setPreventSleepEnabled?.(checked) + if (!update) return + void update.catch(() => setPreventSleep(!checked)) + } + const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ { value: "system", label: language.t("theme.scheme.system") }, { value: "light", label: language.t("theme.scheme.light") }, @@ -915,6 +928,25 @@ export const SettingsGeneralV2: Component = () => { ) + const PowerSection = () => ( + +
+

{language.t("settings.general.section.power")}

+ + + +
+ +
+
+
+
+
+ ) + return ( <>
@@ -940,6 +972,8 @@ export const SettingsGeneralV2: Component = () => { + +
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index d2806fda..98d8548f 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -122,6 +122,12 @@ type PlatformBase = { /** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */ setPinchZoomEnabled?(enabled: boolean): Promise | void + /** Get whether the app keeps the system awake while running (desktop only) */ + getPreventSleepEnabled?(): Promise | boolean + + /** Keep the system awake while the app is running (desktop only) */ + setPreventSleepEnabled?(enabled: boolean): Promise | void + /** Run a desktop-only menu action from the app chrome */ runDesktopMenuAction?(action: DesktopMenuAction): Promise | void diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 181b543c..f94a0fe2 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -560,6 +560,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "تفعيل لجنة الخبراء افتراضيًا", "settings.general.row.expertPanelDefault.description": "بدء المحادثات الجديدة مع تفعيل لجنة الخبراء، بحيث يبدأ زرها مراجعة السياق الحالي عند الطلب.", "settings.general.section.display": "شاشة العرض", + "settings.general.section.power": "الطاقة", "settings.general.row.language.title": "اللغة", "settings.general.row.language.description": "تغيير لغة العرض لـ DeepAgent Code", "settings.general.row.appearance.title": "المظهر", @@ -930,6 +931,8 @@ export const dict = { "تفعيل التخطيط والصفحة الرئيسية والمحرر وواجهة الجلسة المعاد تصميمها", "settings.general.row.pinchZoom.title": "التكبير بالقرص", "settings.general.row.pinchZoom.description": "السماح بإيماءات قرص لوحة اللمس و Ctrl-تمرير للتكبير", + "settings.general.row.preventSleep.title": "منع سكون النظام", + "settings.general.row.preventSleep.description": "إبقاء النظام مستيقظًا أثناء تشغيل التطبيق؛ ويمكن أن تنطفئ الشاشة", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "جار التنزيل...", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index b2ff7bef..31699b30 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -567,6 +567,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "Ativar painel de especialistas por padrão", "settings.general.row.expertPanelDefault.description": "Iniciar novas conversas com o painel de especialistas ativado, para que seu botão inicie uma revisão do contexto atual sob demanda.", "settings.general.section.display": "Tela", + "settings.general.section.power": "Energia", "settings.general.row.language.title": "Idioma", "settings.general.row.language.description": "Alterar o idioma de exibição do DeepAgent Code", "settings.general.row.appearance.title": "Aparência", @@ -954,6 +955,8 @@ export const dict = { "settings.general.row.pinchZoom.title": "Pinçar para zoom", "settings.general.row.pinchZoom.description": "Permitir gestos de pellizco en trackpad y Ctrl-desplazamiento para ampliar", + "settings.general.row.preventSleep.title": "Impedir suspensão do sistema", + "settings.general.row.preventSleep.description": "Manter o sistema ativo enquanto o app está em execução; a tela ainda pode desligar", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "Baixando...", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index cc9f9115..d97be89f 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -632,6 +632,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "Zadano uključi ekspertni panel", "settings.general.row.expertPanelDefault.description": "Pokreni nove razgovore s uključenim ekspertnim panelom, tako da njegovo dugme po potrebi pokreće pregled trenutnog konteksta.", "settings.general.section.display": "Prikaz", + "settings.general.section.power": "Napajanje", "settings.general.row.language.title": "Jezik", "settings.general.row.language.description": "Promijeni jezik prikaza u DeepAgent Code-u", @@ -1027,6 +1028,8 @@ export const dict = { "settings.general.row.pinchZoom.title": "Powiększanie gestem szczypania", "settings.general.row.pinchZoom.description": "Zezwól na powiększanie gestem szczypania na trackpadzie i Ctrl-przewijaniem", + "settings.general.row.preventSleep.title": "Spriječi mirovanje sistema", + "settings.general.row.preventSleep.description": "Zadržava sistem budnim dok je aplikacija pokrenuta; ekran se i dalje može ugasiti", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "Preuzimanje...", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 6af26897..b725d224 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -627,6 +627,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "Aktivér ekspertpanel som standard", "settings.general.row.expertPanelDefault.description": "Start nye samtaler med ekspertpanelet aktiveret, så knappen kan gennemgå den aktuelle kontekst efter behov.", "settings.general.section.display": "Skærm", + "settings.general.section.power": "Strøm", "settings.general.row.language.title": "Sprog", "settings.general.row.language.description": "Ændr visningssproget for DeepAgent Code", @@ -1016,6 +1017,8 @@ export const dict = { "settings.general.row.newLayoutDesigns.description": "Aktivér det redesignede layout, hjem, composer og sessions-UI", "settings.general.row.pinchZoom.title": "Knib for at zoome", "settings.general.row.pinchZoom.description": "Tillad trackpad-knib og Ctrl-scroll til zoom", + "settings.general.row.preventSleep.title": "Forhindr dvale", + "settings.general.row.preventSleep.description": "Hold systemet vågent, mens appen kører; skærmen kan stadig slukkes", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "Downloader...", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index e682c164..f1972566 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -576,6 +576,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "Expertengremium standardmäßig aktivieren", "settings.general.row.expertPanelDefault.description": "Neue Unterhaltungen mit aktiviertem Expertengremium starten, damit dessen Schaltfläche den aktuellen Kontext bei Bedarf prüft.", "settings.general.section.display": "Anzeige", + "settings.general.section.power": "Energie", "settings.general.row.language.title": "Sprache", "settings.general.row.language.description": "Die Anzeigesprache für DeepAgent Code ändern", "settings.general.row.appearance.title": "Erscheinungsbild", @@ -961,6 +962,8 @@ export const dict = { "Überarbeitetes Layout, Startseite, Composer und Sitzungs-UI aktivieren", "settings.general.row.pinchZoom.title": "Zum Zoomen kneifen", "settings.general.row.pinchZoom.description": "Trackpad-Kneifen und Strg-Scrollen zum Zoomen erlauben", + "settings.general.row.preventSleep.title": "Ruhezustand des Systems verhindern", + "settings.general.row.preventSleep.description": "Hält das System wach, solange die App läuft; der Bildschirm kann sich weiterhin ausschalten", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "Wird heruntergeladen...", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index b385af78..c00ee08c 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -247,6 +247,8 @@ export const dict = { "provider.custom.error.required": "Required", "provider.custom.error.duplicate": "Duplicate", "provider.custom.error.context": "Enter a positive whole number, or leave blank", + "provider.custom.error.protocol.ambiguous": + "This endpoint supports multiple authentication styles. Select OpenAI-compatible or Anthropic explicitly.", "provider.disconnect.toast.disconnected.title": "{{provider}} disconnected", "provider.disconnect.toast.disconnected.description": "{{provider}} models are no longer available.", @@ -1204,6 +1206,7 @@ export const dict = { "settings.general.section.sounds": "Sound effects", "settings.general.section.feed": "Feed", "settings.general.section.display": "Display", + "settings.general.section.power": "Power", "settings.general.section.sharing": "Sharing", "settings.general.row.language.title": "Language", @@ -1367,6 +1370,8 @@ export const dict = { "settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI", "settings.general.row.pinchZoom.title": "Pinch to zoom", "settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom", + "settings.general.row.preventSleep.title": "Prevent system sleep", + "settings.general.row.preventSleep.description": "Keep the system awake while the app is running; the display can still turn off", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 5ec07a63..0d6b9085 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -635,6 +635,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "Activar el panel de expertos por defecto", "settings.general.row.expertPanelDefault.description": "Iniciar nuevas conversaciones con el panel de expertos activado, para que su botón revise el contexto actual cuando se solicite.", "settings.general.section.display": "Pantalla", + "settings.general.section.power": "Energía", "settings.general.row.language.title": "Idioma", "settings.general.row.language.description": "Cambiar el idioma de visualización para DeepAgent Code", @@ -1037,6 +1038,8 @@ export const dict = { "settings.general.row.pinchZoom.title": "Pellizcar para ampliar", "settings.general.row.pinchZoom.description": "Permitir gestos de pellizco en trackpad y Ctrl-desplazamiento para ampliar", + "settings.general.row.preventSleep.title": "Evitar la suspensión del sistema", + "settings.general.row.preventSleep.description": "Mantener el sistema activo mientras la app está en ejecución; la pantalla aún puede apagarse", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "Descargando...", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 9e01f181..93df50ca 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -574,6 +574,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "Activer le panel d'experts par défaut", "settings.general.row.expertPanelDefault.description": "Démarrer les nouvelles conversations avec le panel d'experts activé, afin que son bouton examine le contexte actuel à la demande.", "settings.general.section.display": "Affichage", + "settings.general.section.power": "Alimentation", "settings.general.row.language.title": "Langue", "settings.general.row.language.description": "Changer la langue d'affichage pour DeepAgent Code", "settings.general.row.appearance.title": "Apparence", @@ -962,6 +963,8 @@ export const dict = { "Activer la mise en page, l’accueil, le compositeur et l’interface de session repensés", "settings.general.row.pinchZoom.title": "Pincer pour zoomer", "settings.general.row.pinchZoom.description": "Autoriser le pincement du trackpad et Ctrl-défilement pour zoomer", + "settings.general.row.preventSleep.title": "Empêcher la mise en veille du système", + "settings.general.row.preventSleep.description": "Garde le système actif pendant que l'app est en cours d'exécution ; l'écran peut toujours s'éteindre", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "Téléchargement...", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index ad5e45cb..c79ca0f3 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -566,6 +566,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "エキスパートパネルをデフォルトで有効化", "settings.general.row.expertPanelDefault.description": "新しい会話をエキスパートパネル有効の状態で開始し、ボタンで現在のコンテキストをいつでも会議レビューできるようにします。", "settings.general.section.display": "ディスプレイ", + "settings.general.section.power": "電源", "settings.general.row.language.title": "言語", "settings.general.row.language.description": "DeepAgent Codeの表示言語を変更します", "settings.general.row.appearance.title": "外観", @@ -943,6 +944,8 @@ export const dict = { "再設計されたレイアウト、ホーム、コンポーザー、セッションUIを有効にします", "settings.general.row.pinchZoom.title": "ピンチでズーム", "settings.general.row.pinchZoom.description": "トラックパッドのピンチとCtrlスクロールでズームを許可します", + "settings.general.row.preventSleep.title": "システムのスリープを防止", + "settings.general.row.preventSleep.description": "アプリの実行中はシステムをスリープさせません。ディスプレイはオフにできます", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "ダウンロード中...", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index d8f0e408..1b742aee 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -562,6 +562,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "전문가 패널을 기본으로 활성화", "settings.general.row.expertPanelDefault.description": "새 대화를 전문가 패널이 활성화된 상태로 시작하여 버튼으로 현재 컨텍스트를 필요할 때 검토할 수 있게 합니다.", "settings.general.section.display": "디스플레이", + "settings.general.section.power": "전원", "settings.general.row.language.title": "언어", "settings.general.row.language.description": "DeepAgent Code 표시 언어 변경", "settings.general.row.appearance.title": "모양", @@ -934,6 +935,8 @@ export const dict = { "settings.general.row.newLayoutDesigns.description": "재설계된 레이아웃, 홈, 작성기 및 세션 UI를 활성화합니다", "settings.general.row.pinchZoom.title": "핀치로 확대/축소", "settings.general.row.pinchZoom.description": "트랙패드 핀치와 Ctrl-스크롤 제스처로 확대/축소를 허용합니다", + "settings.general.row.preventSleep.title": "시스템 절전 방지", + "settings.general.row.preventSleep.description": "앱이 실행되는 동안 시스템을 절전 상태로 전환하지 않습니다. 디스플레이는 꺼질 수 있습니다", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "다운로드 중...", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 2d21e9a8..d0a87222 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -635,6 +635,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "Aktiver ekspertpanel som standard", "settings.general.row.expertPanelDefault.description": "Start nye samtaler med ekspertpanelet aktivert, slik at knappen kan gjennomgå gjeldende kontekst ved behov.", "settings.general.section.display": "Skjerm", + "settings.general.section.power": "Strøm", "settings.general.row.language.title": "Språk", "settings.general.row.language.description": "Endre visningsspråket for DeepAgent Code", @@ -1023,6 +1024,8 @@ export const dict = { "settings.general.row.newLayoutDesigns.description": "Aktivér det redesignede layout, hjem, composer og sessions-UI", "settings.general.row.pinchZoom.title": "Knib for at zoome", "settings.general.row.pinchZoom.description": "Tillad trackpad-knib og Ctrl-scroll til zoom", + "settings.general.row.preventSleep.title": "Forhindre hvilemodus", + "settings.general.row.preventSleep.description": "Hold systemet våkent mens appen kjører; skjermen kan fortsatt slås av", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "Laster ned...", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 8ade8e49..ea9daff2 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -566,6 +566,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "Domyślnie włącz panel ekspertów", "settings.general.row.expertPanelDefault.description": "Rozpoczynaj nowe rozmowy z włączonym panelem ekspertów, aby jego przycisk mógł na żądanie przeglądać bieżący kontekst.", "settings.general.section.display": "Ekran", + "settings.general.section.power": "Zasilanie", "settings.general.row.language.title": "Język", "settings.general.row.language.description": "Zmień język wyświetlania dla DeepAgent Code", "settings.general.row.appearance.title": "Wygląd", @@ -949,6 +950,8 @@ export const dict = { "settings.general.row.pinchZoom.title": "Powiększanie gestem szczypania", "settings.general.row.pinchZoom.description": "Zezwól na powiększanie gestem szczypania na trackpadzie i Ctrl-przewijaniem", + "settings.general.row.preventSleep.title": "Zapobiegaj uśpieniu systemu", + "settings.general.row.preventSleep.description": "Utrzymuj system aktywny, gdy aplikacja jest uruchomiona; ekran nadal może się wyłączyć", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "Pobieranie...", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index c88ca046..7e599757 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -631,6 +631,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "Включить экспертную панель по умолчанию", "settings.general.row.expertPanelDefault.description": "Начинать новые беседы с включённой экспертной панелью, чтобы её кнопка по запросу проверяла текущий контекст.", "settings.general.section.display": "Дисплей", + "settings.general.section.power": "Питание", "settings.general.row.language.title": "Язык", "settings.general.row.language.description": "Изменить язык отображения DeepAgent Code", @@ -1028,6 +1029,8 @@ export const dict = { "Включить обновленный макет, главную страницу, композер и интерфейс сессии", "settings.general.row.pinchZoom.title": "Масштабирование щипком", "settings.general.row.pinchZoom.description": "Разрешить масштабирование щипком на трекпаде и Ctrl-прокруткой", + "settings.general.row.preventSleep.title": "Запретить сон системы", + "settings.general.row.preventSleep.description": "Не давать системе засыпать, пока приложение запущено; экран по-прежнему может гаснуть", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "Загрузка...", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index d9822fb2..8cea2edd 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -626,6 +626,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "เปิดใช้คณะผู้เชี่ยวชาญโดยค่าเริ่มต้น", "settings.general.row.expertPanelDefault.description": "เริ่มการสนทนาใหม่โดยเปิดใช้คณะผู้เชี่ยวชาญ เพื่อให้ปุ่มสามารถตรวจทานบริบทปัจจุบันได้ตามต้องการ", "settings.general.section.display": "การแสดงผล", + "settings.general.section.power": "พลังงาน", "settings.general.row.language.title": "ภาษา", "settings.general.row.language.description": "เปลี่ยนภาษาที่แสดงสำหรับ DeepAgent Code", @@ -1011,6 +1012,8 @@ export const dict = { "settings.general.row.newLayoutDesigns.description": "เปิดใช้เลย์เอาต์ หน้าแรก ตัวเขียน และ UI เซสชันที่ออกแบบใหม่", "settings.general.row.pinchZoom.title": "จีบนิ้วเพื่อซูม", "settings.general.row.pinchZoom.description": "อนุญาตท่าทางจีบนิ้วบนแทร็กแพดและ Ctrl-เลื่อนเพื่อซูม", + "settings.general.row.preventSleep.title": "ป้องกันการสลีปของระบบ", + "settings.general.row.preventSleep.description": "ทำให้ระบบทำงานต่อเนื่องขณะแอปกำลังทำงาน หน้าจอยังคงดับได้", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "กำลังดาวน์โหลด...", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index ce03a0a0..e4abc007 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -637,6 +637,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "Uzman panelini varsayılan olarak etkinleştir", "settings.general.row.expertPanelDefault.description": "Yeni sohbetleri uzman paneli etkin olarak başlatın; böylece düğmesi istendiğinde mevcut bağlamı inceler.", "settings.general.section.display": "Ekran", + "settings.general.section.power": "Güç", "settings.general.row.language.title": "Dil", "settings.general.row.language.description": "DeepAgent Code'un görünüm dilini değiştirin", @@ -1036,6 +1037,8 @@ export const dict = { "settings.general.row.pinchZoom.title": "Sıkıştırarak yakınlaştır", "settings.general.row.pinchZoom.description": "Trackpad sıkıştırma ve Ctrl-kaydırma hareketleriyle yakınlaştırmaya izin ver", + "settings.general.row.preventSleep.title": "Sistem uykusunu engelle", + "settings.general.row.preventSleep.description": "Uygulama çalışırken sistemi uyanık tutar; ekran yine de kapanabilir", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "İndiriliyor...", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index 931f58d4..7a82b2a5 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -735,6 +735,7 @@ export const dict = { "settings.general.row.expertPanelDefault.title": "Типово вмикати експертну панель", "settings.general.row.expertPanelDefault.description": "Починати нові розмови з увімкненою експертною панеллю, щоб її кнопка за потреби переглядала поточний контекст.", "settings.general.section.display": "Дисплей", + "settings.general.section.power": "Живлення", "settings.general.row.language.title": "Мова", "settings.general.row.language.description": "Змінити мову інтерфейсу DeepAgent Code", @@ -1044,6 +1045,8 @@ export const dict = { "Включить обновленный макет, главную страницу, композер и интерфейс сессии", "settings.general.row.pinchZoom.title": "Масштабування щипком", "settings.general.row.pinchZoom.description": "Разрешить масштабирование щипком на трекпаде и Ctrl-прокруткой", + "settings.general.row.preventSleep.title": "Запобігати сну системи", + "settings.general.row.preventSleep.description": "Не давати системі засинати, доки застосунок працює; екран все одно може вимикатися", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "Завантаження...", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index f9c26a02..3b22bd5e 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -261,6 +261,8 @@ export const dict = { "provider.custom.error.required": "必填", "provider.custom.error.duplicate": "重复", "provider.custom.error.context": "请输入正整数,或留空", + "provider.custom.error.protocol.ambiguous": + "该端点同时支持多种认证方式,无法安全地自动判断协议。请明确选择 OpenAI-compatible 或 Anthropic。", "provider.disconnect.toast.disconnected.title": "{{provider}} 已断开连接", "provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。", @@ -995,6 +997,7 @@ export const dict = { "settings.general.section.sounds": "音效", "settings.general.section.feed": "动态", "settings.general.section.display": "显示", + "settings.general.section.power": "电源", "settings.general.section.sharing": "分享", "settings.general.row.shareUrl.title": "分享服务器地址", "settings.general.row.shareUrl.description": "分享会话时使用的服务器基础地址。留空则使用默认值。", @@ -1442,6 +1445,8 @@ export const dict = { "settings.general.row.newLayoutDesigns.description": "启用重新设计的布局、首页、输入器和会话界面", "settings.general.row.pinchZoom.title": "双指缩放", "settings.general.row.pinchZoom.description": "允许触控板双指和 Ctrl 滚动手势缩放", + "settings.general.row.preventSleep.title": "阻止系统休眠", + "settings.general.row.preventSleep.description": "应用运行时保持系统唤醒,显示器仍可关闭", "settings.general.row.zoom.title": "缩放比例", "settings.general.row.zoom.description": "设置界面缩放比例", "settings.updates.action.downloading": "正在下载...", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index fb825dae..a54da664 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -208,6 +208,8 @@ export const dict = { "provider.custom.error.required": "必填", "provider.custom.error.duplicate": "重複", "provider.custom.error.context": "請輸入正整數,或留空", + "provider.custom.error.protocol.ambiguous": + "此端點同時支援多種驗證方式,無法安全地自動判斷協定。請明確選擇 OpenAI-compatible 或 Anthropic。", "provider.disconnect.toast.disconnected.title": "{{provider}} 已中斷連線", "provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。", @@ -781,6 +783,7 @@ export const dict = { "settings.general.section.sounds": "音效", "settings.general.section.feed": "資訊流", "settings.general.section.display": "顯示", + "settings.general.section.power": "電源", "settings.general.section.sharing": "分享", "settings.general.row.shareUrl.title": "分享伺服器網址", "settings.general.row.shareUrl.description": "分享工作階段時使用的伺服器基礎網址。留空則使用預設值。", @@ -1164,6 +1167,8 @@ export const dict = { "settings.general.row.newLayoutDesigns.description": "啟用重新設計的布局、首頁、輸入器和會話介面", "settings.general.row.pinchZoom.title": "雙指縮放", "settings.general.row.pinchZoom.description": "允許觸控板雙指和 Ctrl 捲動手勢縮放", + "settings.general.row.preventSleep.title": "防止系統休眠", + "settings.general.row.preventSleep.description": "應用程式執行時保持系統喚醒,顯示器仍可關閉", "settings.general.row.zoom.title": "Zoom level", "settings.general.row.zoom.description": "Set the interface zoom level", "settings.updates.action.downloading": "正在下載...", diff --git a/packages/core/src/deepagent/orchestration.ts b/packages/core/src/deepagent/orchestration.ts index 36dc7c63..02a32664 100644 --- a/packages/core/src/deepagent/orchestration.ts +++ b/packages/core/src/deepagent/orchestration.ts @@ -306,7 +306,7 @@ export const buildOrchestrationSection = (mode: AgentMode): string | null => { "抑制信号(命中则本体做,禁止过度编排):单文件;机制已明确;纯机械改动(改名/typo/格式);用户要求快速/直接。", "", "关键判定(reviewer 的 verdict、研究结果的合并)走结构化结果:调 `task` 时传 `output_schema`(reviewer→ReviewResult,researcher→ResearchResult),不要依赖散文解析。", - `扇出规模自控(宽松上限,非硬性):单次编排子 agent 总数控制在 ${DEFAULT_MAX_FANOUT} 个以内,单轮并行不超过 ${DEFAULT_MAX_CONCURRENCY} 个;确有必要可分多轮,但不要一次性发起远超此规模的 task。本轮的具体扇出建议数见对话末尾 。`, + `扇出规模自控(宽松上限,非硬性):单次编排子 agent 总数控制在 ${DEFAULT_MAX_FANOUT} 个以内,单轮并行不超过 ${DEFAULT_MAX_CONCURRENCY} 个;确有必要可分多轮,但不要一次性发起远超此规模的 task。本轮的具体扇出建议数由系统通过 提供。`, ] if (mode === "ultra") { lines.push("当前为 ultra:默认倾向编排并可多轮迭代。") diff --git a/packages/core/src/deepagent/prompt-policy.ts b/packages/core/src/deepagent/prompt-policy.ts index bd126020..3af5dfd2 100644 --- a/packages/core/src/deepagent/prompt-policy.ts +++ b/packages/core/src/deepagent/prompt-policy.ts @@ -112,9 +112,9 @@ export type PreviousResults = { // applyCaching). Anything that changes per-round (round number, previous-round results, token // budget, the concrete fan-out verdict) is a cache-buster: it invalidates the whole prefix AND all // history after it on every intra-turn call. All such volatile state lives in -// `buildVolatileRoundContext` instead, which the caller appends to the LAST user message (after the -// breakpoint) so the model still sees it without churning the prefix. When editing, ask: "does this -// value differ between two turns of the same session?" If yes, it belongs in the volatile context. +// `buildVolatileRoundContext` instead, which the caller sends as a separate system message. This may +// reduce cross-round history cache reuse, but runtime control state must never masquerade as a new +// user request. export const buildSystemPrompt = (ctx: PromptContext): string => { const sections: string[] = [] @@ -130,7 +130,7 @@ export const buildSystemPrompt = (ctx: PromptContext): string => { // L2 (v3.8.0 §L2): orchestration guidance, injected after the tools/task section per the design. // Tier-gated by mode; buildOrchestrationSection returns null when there is nothing to add. The // per-turn fan-out DECISION (concrete counts) is intentionally NOT passed here — it is volatile and - // rendered by buildVolatileRoundContext; the system block keeps only the stable generic guidance. + // rendered by buildVolatileRoundContext; this block keeps only the stable generic guidance. const orchestration = buildOrchestrationSection(ctx.mode) if (orchestration) sections.push(orchestration) @@ -146,8 +146,7 @@ export const buildSystemPrompt = (ctx: PromptContext): string => { // null (no section), and a later retrieval-enabled round returns non-null, which USED to insert the // section into the cached prefix mid-session and bust the cache for the rest of the session. Mirror // the T4.4 fan-out guard: knowledge is per-session-volatile w.r.t. WHEN it first appears, so it does - // NOT belong in the byte-stable prefix. It is rendered in buildVolatileRoundContext instead (tail, - // after the cache breakpoint), so the model still sees it without ever churning the prefix. + // NOT belong in the byte-stable base prompt. It is rendered in buildVolatileRoundContext instead. sections.push(constraintsSection(ctx.mode)) @@ -158,11 +157,9 @@ export const buildSystemPrompt = (ctx: PromptContext): string => { return sections.filter(Boolean).join("\n\n") } -// Volatile per-turn state that must NOT enter the cached system prefix. Rendered into a single -// `` block that the caller appends to the last user message (after the -// cache breakpoint). Returns "" when there is nothing round-specific to surface (e.g. first turn, -// no previous results) so the caller can skip injection entirely. Keep the ORDER and wording here -// free to change per turn — that is the whole point; only buildSystemPrompt must stay stable. +// Volatile per-turn state that must NOT enter the cached base system prompt. Rendered into a single +// `` block that the caller sends as a separate system message before user +// history. Keep the order and wording here free to change; only buildSystemPrompt must stay stable. export const buildVolatileRoundContext = (ctx: PromptContext): string => { const sections: string[] = [] @@ -172,8 +169,7 @@ export const buildVolatileRoundContext = (ctx: PromptContext): string => { sections.push(["# 本轮状态 (round context)", "", roundLine].join("\n")) // MEDIUM cache-buster fix: the current date was moved OUT of environmentSection (cached prefix) — - // it advances at midnight and would bust the whole prefix once per day. Render it here in the tail - // so the model still knows "today" without churning the cache. + // it advances at midnight and would bust the base prompt once per day. Render it here instead. if (ctx.environment.date) { sections.push(["# 日期 (date)", "", `- Date: ${ctx.environment.date}`].join("\n")) } @@ -185,7 +181,7 @@ export const buildVolatileRoundContext = (ctx: PromptContext): string => { } // Task objective + goals/criteria: the current target can be re-seeded on a continue round (the - // supervisor advances the goal), so it is round-derived. Keep it in the tail. + // supervisor advances the goal), so it is round-derived. Keep it in the runtime update. if (ctx.task.userRequest || ctx.task.goals.length > 0) { sections.push(taskSection(ctx.task)) } @@ -196,9 +192,8 @@ export const buildVolatileRoundContext = (ctx: PromptContext): string => { // BUG #5 (prompt-cache): knowledge is retrieved LAZILY (null on a fresh store round 1, non-null on a // later retrieval-enabled round). It used to live in the cached system prefix, so its late appearance - // busted the prefix mid-session (~10× cost). It is advisory, round-derived context — render it here in - // the volatile tail (after the cache breakpoint) so the model still sees it without ever churning the - // prefix. The `knowledgeEnabled(mode)` gate is preserved from the original prefix placement. + // busted the base prompt mid-session (~10× cost). It is advisory, round-derived context and belongs + // in this separate runtime update. The `knowledgeEnabled(mode)` gate is preserved. if (ctx.knowledge && knowledgeEnabled(ctx.mode)) { sections.push(knowledgeSection(ctx.knowledge)) } @@ -243,15 +238,15 @@ const identitySection = (mode: AgentMode): string => { "2. 验证通过则完成,失败则诊断 → 修复 → 再验证", "3. 不盲目重试,不做无证据的猜测性修改", "4. 工具执行由运行时负责,我负责思维和决策", - "5. 当前轮次 / 阶段 / 上轮结果 / 预算见对话末尾的 ", + "5. 当前轮次 / 阶段 / 上轮结果 / 预算由系统通过 提供", ].join("\n") } const environmentSection = (env: EnvironmentContext): string => { // NOTE (prompt-cache): `- Date` is intentionally NOT rendered here. The date advances at midnight, // so baking it into the cached prefix busts the whole prefix once per day (MEDIUM cache-buster). It - // is rendered in buildVolatileRoundContext (tail, after the cache breakpoint) instead — same policy - // as every other volatile env-derived value. Everything left here is session-stable. + // is rendered in the separate runtime system message instead. Everything left here is + // session-stable. const lines = [ "# Environment", "", @@ -277,14 +272,10 @@ const activationSection = (activation: ActivationDecision): string => { // §5b fan-out verdict rendered for the volatile block. Mirrors the advisory numbers that // buildOrchestrationSection used to inline, but kept OUT of the cached system prefix because the // verdict is derived from this turn's task complexity and changes turn-to-turn. -const fanoutVerdictLines = (decision: FanoutDecision): string => { - if (!decision.orchestrate) { - return [ - "# 本轮调度判定 (orchestration verdict)", - "", - `不建议扇出(level=${decision.level},tier=${decision.tier},complexity=${decision.complexity})。本体直接完成,除非用户明确要求深入/多角度。`, - ].join("\n") - } +const fanoutVerdictLines = (decision: FanoutDecision): string | null => { + // A negative verdict has no action for the model to take. Repeating it on every tool turn makes + // some models echo the verdict verbatim and adds noise even when they do not. + if (!decision.orchestrate) return null return [ "# 本轮调度判定 (orchestration verdict)", "", @@ -411,6 +402,7 @@ const constraintsSection = (mode: AgentMode): string => { "- Run validation after making changes", "- If validation passes, the task is complete — do not continue optimizing", "- If you cannot complete the task, explain why clearly", + "- Apply runtime control state silently; never quote or report its tags, orchestration levels, plan status, or token budget unless the user explicitly asks for diagnostics", ] if (mode === "max") { lines.push("- Knowledge synthesis is available — use refs for guidance, not verbatim copying") diff --git a/packages/core/src/deepagent/session-state.ts b/packages/core/src/deepagent/session-state.ts index 9f0f520c..a807d4af 100644 --- a/packages/core/src/deepagent/session-state.ts +++ b/packages/core/src/deepagent/session-state.ts @@ -104,6 +104,9 @@ export type SessionRunState = { // call) are never mistaken for genuine new user inputs. undefined = not yet recorded (session // created before this field was added; treated as "initial" on first observation). lastAdmissionUserMessageId: string | undefined + // The last soft plan-gate warning claimed by a tool call. A stale latch can span many tool calls; + // persisting its fingerprint prevents duplicate process-log warnings without touching history. + lastPlanGateNudgeFingerprint: string | null } // V3.9 §D: session-state pointer to a running goal. The GoalLoop's GoalStatus (persisted in the @@ -165,6 +168,7 @@ export const getOrCreate = (sessionId: string, mode: AgentMode): SessionRunState createdAt: new Date().toISOString(), completedAt: null, lastAdmissionUserMessageId: undefined, + lastPlanGateNudgeFingerprint: null, } sessions.set(sessionId, state) saveToDisk() @@ -455,6 +459,14 @@ export const clearPlanStale = (sessionId: string): void => { saveToDisk() } +export const claimPlanGateNudge = (sessionId: string, fingerprint: string): boolean => { + const state = sessions.get(sessionId) + if (!state || state.lastPlanGateNudgeFingerprint === fingerprint) return false + state.lastPlanGateNudgeFingerprint = fingerprint + saveToDisk() + return true +} + // U1 anti-deadlock: record that the plan gate just blocked a mutating tool on a stale plan. Advances // the runtime grace counter so shouldGraceRelease can fire without the model cooperating. export const recordPlanGateBlock = (sessionId: string): void => { @@ -615,6 +627,7 @@ function normalizeState(state: SessionRunState): SessionRunState { // Backfill: sessions persisted before V4.0 have no panelRounds → null (defaults to "single"). panelRounds: state.panelRounds ?? null, activeGoal: state.activeGoal ?? null, + lastPlanGateNudgeFingerprint: state.lastPlanGateNudgeFingerprint ?? null, } // PR-4 migration cleanup: the legacy `suppressedFingerprints: string[]` field is carried through by // the `...state` spread above. Once its contents are migrated into `suppressedValidations`, drop the diff --git a/packages/core/src/skill/discovery.ts b/packages/core/src/skill/discovery.ts index e20f8a18..54a7c244 100644 --- a/packages/core/src/skill/discovery.ts +++ b/packages/core/src/skill/discovery.ts @@ -8,6 +8,7 @@ import { httpClient } from "../effect/app-node-platform" import { FSUtil } from "../fs-util" import { Global } from "../global" import { AbsolutePath } from "../schema" +import { Hash } from "../util/hash" import * as Log from "../util/log" const skillConcurrency = 4 @@ -109,7 +110,7 @@ export const layer = Layer.effect( ) if (!data) return [] - const sourceRoot = path.resolve(global.cache, "skills", Bun.hash(base).toString(16)) + const sourceRoot = path.resolve(global.cache, "skills", Hash.fast(base)) return yield* Effect.forEach( data.skills.flatMap((skill) => { if (!isSafeSegment(skill.name)) { diff --git a/packages/core/test/deepagent/orchestration.test.ts b/packages/core/test/deepagent/orchestration.test.ts index aa41a1a4..10bb663b 100644 --- a/packages/core/test/deepagent/orchestration.test.ts +++ b/packages/core/test/deepagent/orchestration.test.ts @@ -219,7 +219,7 @@ describe("§5b orchestration section is stable (no per-turn verdict inlined)", ( expect(decideFanout({ mode: "high", signals }).orchestrate).toBe(false) }) - test("section points the model to the tail-appended round context", () => { + test("section points the model to the runtime system context", () => { const section = buildOrchestrationSection("high")! expect(section).toContain("deepagent-round-context") }) diff --git a/packages/core/test/deepagent/plan-controller.test.ts b/packages/core/test/deepagent/plan-controller.test.ts index 0c545572..b993dcc8 100644 --- a/packages/core/test/deepagent/plan-controller.test.ts +++ b/packages/core/test/deepagent/plan-controller.test.ts @@ -134,7 +134,7 @@ describe("planGate (before_tool_use soft gate)", () => { }) // DESIGN (aligned with codex exec_policy): a stale plan ledger NEVER denies tool execution — it - // WARNS (tool runs, reminder attached) so the model is nudged to re-sync without being deadlocked. + // WARNS while the tool runs; dispatch records it outside model-visible tool output. // This holds in every mode, including high+ (the old code hard-blocked here, which caused the // production deadlock). The only remaining hard block is the U9 per-step binding gate, covered below. test("warns (never blocks) on a mutating tool when stale in high+ mode", () => { diff --git a/packages/core/test/deepagent/plan-gate-loop.test.ts b/packages/core/test/deepagent/plan-gate-loop.test.ts index 83dc5291..ec3b5155 100644 --- a/packages/core/test/deepagent/plan-gate-loop.test.ts +++ b/packages/core/test/deepagent/plan-gate-loop.test.ts @@ -8,8 +8,8 @@ import { planGate, HookPolicy } from "../../src/deepagent/hooks" // U1 end-to-end contract (the exact decision the tools.ts chokepoint computes). DESIGN (aligned with // codex exec_policy): plan-ledger state is orthogonal to whether a tool may run, so a stale plan -// NEVER hard-blocks a mutating tool — it WARNS (tool runs, reminder attached) so the model is nudged -// to re-sync without being denied its tools. Read/plan tools always pass. The only hard block that +// NEVER hard-blocks a mutating tool — it WARNS while the tool runs; dispatch keeps that warning out +// of model-visible output and finalization owns plan enforcement. Read/plan tools always pass. The only hard block that // remains is the U9 per-step binding gate (strict hard modes), and even that has a runtime grace // release so it can never permanently deadlock. This mirrors session/tools.ts without booting the // full session loop. @@ -88,7 +88,7 @@ describe("U1 soft-gate loop (chokepoint contract)", () => { test("read-only bash (ls/git status/grep/sed -n/sort) is allowed even while the plan is stale", () => { SessionState.getOrCreate("gate-ro", "xhigh") SessionState.markPlanStale("gate-ro", "validation_failed") - // a mutating bash command warns (runs + reminder), never a hard block… + // a mutating bash command warns but still runs, never a hard block… expect(decideAt("gate-ro", "bash", "xhigh", "rm -rf build").decision).toBe("warn") // …and read-only shell inspections pass outright (isMutating=false → allow). expect(decideAt("gate-ro", "bash", "xhigh", "ls -la").decision).toBe("allow") diff --git a/packages/core/test/deepagent/prompt-policy.test.ts b/packages/core/test/deepagent/prompt-policy.test.ts index f3bdc5e6..24a7ed27 100644 --- a/packages/core/test/deepagent/prompt-policy.test.ts +++ b/packages/core/test/deepagent/prompt-policy.test.ts @@ -186,4 +186,22 @@ describe("buildVolatileRoundContext", () => { // still non-empty: round/stage are always present expect(vol).toContain("第 1 轮") }) + + test("omits a non-actionable fan-out verdict", () => { + const vol = buildVolatileRoundContext({ + ...ctxAt(1, 100_000), + fanoutDecision: { + orchestrate: false, + level: 0, + tier: 1, + complexity: 0, + researchers: 0, + reviewers: 0, + maxConcurrency: 4, + }, + }) + expect(vol).not.toContain("orchestration verdict") + expect(vol).not.toContain("本体直接完成") + expect(vol).not.toContain("level=0") + }) }) diff --git a/packages/core/test/deepagent/session-state-plan-latch.test.ts b/packages/core/test/deepagent/session-state-plan-latch.test.ts index 219b27da..0cf40403 100644 --- a/packages/core/test/deepagent/session-state-plan-latch.test.ts +++ b/packages/core/test/deepagent/session-state-plan-latch.test.ts @@ -75,6 +75,13 @@ describe("session-state plan latch", () => { expect(reloaded.planLatch.latch).toBe("stale") expect(reloaded.planLatch.stale_reason).toBe("no_progress") }) + + test("a plan-gate nudge fingerprint is claimed only once", () => { + SessionState.getOrCreate("latch-s7", "high") + expect(SessionState.claimPlanGateNudge("latch-s7", "stale:user_appended:0")).toBe(true) + expect(SessionState.claimPlanGateNudge("latch-s7", "stale:user_appended:0")).toBe(false) + expect(SessionState.claimPlanGateNudge("latch-s7", "stale:tool_failed:0")).toBe(true) + }) }) // U10 step-reporting: the mutation-since-report counter and evidence plumbing on the production seam. diff --git a/packages/deepagent-code/script/build-node.ts b/packages/deepagent-code/script/build-node.ts index e605ce37..e5dbf12a 100755 --- a/packages/deepagent-code/script/build-node.ts +++ b/packages/deepagent-code/script/build-node.ts @@ -34,9 +34,18 @@ if (!result.success) throw new AggregateError(result.logs, "Failed to build the // keep local usernames/workspace paths out of release artifacts. const buildRoot = path.resolve(dir, "../..") const bundle = Bun.file("./dist/node/node.js") +const source = await bundle.text() +// The plugin context exposes Bun.$ only behind a `typeof Bun` guard. Every other direct Bun API +// in this Node-targeted bundle is an unreviewed runtime dependency and must fail the desktop build. +const unsupportedBunAPIs = [...new Set(source.match(/\bBun\.[A-Za-z_$][A-Za-z0-9_$]*/g) ?? [])].filter( + (api) => api !== "Bun.$", +) +if (unsupportedBunAPIs.length > 0) { + throw new Error(`Node server bundle contains Bun-only runtime APIs: ${unsupportedBunAPIs.join(", ")}`) +} await Bun.write( bundle, - (await bundle.text()) + source .replaceAll(buildRoot, "/__deepagent_build__") .replaceAll(buildRoot.replaceAll("\\", "/"), "/__deepagent_build__") .replaceAll(buildRoot.replaceAll("\\", "\\\\"), "/__deepagent_build__"), diff --git a/packages/deepagent-code/src/deepagent/git-groundtruth.ts b/packages/deepagent-code/src/deepagent/git-groundtruth.ts index a5a819c5..8f7e5a2e 100644 --- a/packages/deepagent-code/src/deepagent/git-groundtruth.ts +++ b/packages/deepagent-code/src/deepagent/git-groundtruth.ts @@ -1,4 +1,6 @@ import path from "node:path" +import { buffer } from "node:stream/consumers" +import { Process } from "@/util/process" // V3.1 A4 (P0 fix): real runner ground truth for the round report's change-surface dimension. // The round report reconciles MODEL CLAIMS against RUNNER GROUND TRUTH; the changed-file list and @@ -19,7 +21,14 @@ const EMPTY: GitGroundTruth = { changed_files: [], diff_stat: null, repo_root: n const run = async (args: readonly string[], cwd: string, timeoutMs: number): Promise => { try { - const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe", env: { ...process.env } }) + const proc = Process.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + env: { ...process.env }, + }) + const stdout = proc.stdout + if (!stdout) return null // P2-5: race read+exit against a timeout sentinel; on timeout kill and resolve null instead of // awaiting stdout that may never close after kill. let timer: ReturnType | undefined @@ -32,8 +41,8 @@ const run = async (args: readonly string[], cwd: string, timeoutMs: number): Pro }, timeoutMs) }) const completed = (async () => { - const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]) - return { stdout, exitCode } as const + const [output, exitCode] = await Promise.all([buffer(stdout), proc.exited]) + return { stdout: output.toString(), exitCode } as const })() const outcome = await Promise.race([completed, timeout]) if (timer) clearTimeout(timer) diff --git a/packages/deepagent-code/src/deepagent/validation-exec.ts b/packages/deepagent-code/src/deepagent/validation-exec.ts index d69b0b01..a3ef0915 100644 --- a/packages/deepagent-code/src/deepagent/validation-exec.ts +++ b/packages/deepagent-code/src/deepagent/validation-exec.ts @@ -1,4 +1,6 @@ import { AgentGateway } from "@deepagent-code/core/agent-gateway" +import { buffer } from "node:stream/consumers" +import { Process } from "@/util/process" // V3 A3: real validation executor. Runs the workspace validation commands (typecheck / lint / // test, inferred by workspace-context) and maps each to the universal ValidationResult the @@ -15,12 +17,15 @@ export const runValidationCommands = async ( for (const command of commands) { const started = Date.now() try { - const proc = Bun.spawn(["sh", "-c", command], { + const proc = Process.spawn(["sh", "-c", command], { cwd, stdout: "pipe", stderr: "pipe", env: { ...process.env }, }) + const stdout = proc.stdout + const stderr = proc.stderr + if (!stdout || !stderr) throw new Error("Validation process output is unavailable") // P2-5: race the full read+exit against a timeout sentinel. On timeout we kill the process // AND resolve immediately to a failed result, instead of awaiting stdout that may never close // after kill (the previous code could hang on `Response(proc.stdout).text()`). @@ -34,9 +39,8 @@ export const runValidationCommands = async ( }, timeoutMs) }) const completed = (async () => { - const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]) - const exitCode = await proc.exited - return { stdout, stderr, exitCode } as const + const [output, error, exitCode] = await Promise.all([buffer(stdout), buffer(stderr), proc.exited]) + return { stdout: output.toString(), stderr: error.toString(), exitCode } as const })() const outcome = await Promise.race([completed, timeout]) diff --git a/packages/deepagent-code/src/mcp/secret-store.ts b/packages/deepagent-code/src/mcp/secret-store.ts index b76ba5f6..74a00835 100644 --- a/packages/deepagent-code/src/mcp/secret-store.ts +++ b/packages/deepagent-code/src/mcp/secret-store.ts @@ -7,6 +7,7 @@ import { FSUtil } from "@deepagent-code/core/fs-util" import { ConfigMCPV1 } from "@deepagent-code/core/v1/config/mcp" import { McpCatalog } from "./catalog" import * as Log from "@deepagent-code/core/util/log" +import { buffer } from "node:stream/consumers" const log = Log.create({ service: "mcp.secret" }) @@ -232,16 +233,15 @@ export namespace SecretStore { put: async (account, secret) => { // secret-tool reads the secret from stdin so it never appears in the process table. const label = `${KEYCHAIN_SERVICE}:${account}` - const proc = Bun.spawn( + const proc = Process.spawn( ["secret-tool", "store", "--label", label, "service", KEYCHAIN_SERVICE, "account", account], - { stdin: "pipe", stdout: "pipe", stderr: "pipe" }, + { stdin: "pipe", stdout: "ignore", stderr: "pipe" }, ) - proc.stdin.write(secret) - proc.stdin.end() - const code = await proc.exited + if (!proc.stdin || !proc.stderr) throw new Error("secret-tool process pipes are unavailable") + proc.stdin.end(secret) + const [code, stderr] = await Promise.all([proc.exited, buffer(proc.stderr)]) if (code !== 0) { - const err = await new Response(proc.stderr).text() - throw new Error(`secret-tool store failed (code ${code}): ${err.trim()}`) + throw new Error(`secret-tool store failed (code ${code}): ${stderr.toString().trim()}`) } }, get: async (account) => { diff --git a/packages/deepagent-code/src/provider/discovery-cache.ts b/packages/deepagent-code/src/provider/discovery-cache.ts index 0a5384c2..786c53a9 100644 --- a/packages/deepagent-code/src/provider/discovery-cache.ts +++ b/packages/deepagent-code/src/provider/discovery-cache.ts @@ -8,11 +8,14 @@ import type { EffectFlock } from "@deepagent-code/core/util/effect-flock" import { discoverProviderModels, isChatModel, + isProviderDiscoveryAuthError, type DiscoveredModel, type ProviderDiscoveryKind, } from "./model-discovery" const log = Log.create({ service: "provider-discovery-cache" }) +const AUTH_FAILURE_RETRY_MS = 60_000 +const authFailures = new Map() // Bounds on what an untrusted /models endpoint can inject into the provider model map. `id` becomes a // map key and `api.id` (flows into request payloads); `name` is display-only. Cap both count and @@ -35,7 +38,11 @@ const sanitizeModels = (models: DiscoveredModel[]): DiscoveredModel[] => { if (seen.has(id)) continue seen.add(id) const rawName = typeof model.name === "string" && model.name ? model.name : id - out.push({ id, name: rawName.slice(0, MAX_MODEL_NAME_LENGTH) }) + out.push({ + id, + name: rawName.slice(0, MAX_MODEL_NAME_LENGTH), + ...(model.protocols ? { protocols: model.protocols } : {}), + }) if (out.length >= MAX_DISCOVERED_MODELS) break } return out @@ -44,8 +51,8 @@ const sanitizeModels = (models: DiscoveredModel[]): DiscoveredModel[] => { // Runtime model discovery for third-party providers that opt in with `discovery: true`. // The live /models list is cached to disk (mtime + TTL, cross-process flock) exactly like the // models.dev catalog cache, so a fresh instance start reuses the list and only refetches after the -// TTL lapses. Discovery is best-effort: a failed fetch falls back to the last good disk copy, and a -// total miss returns [] so the provider load never blocks on the network. +// TTL lapses. Transient failures may use the last good copy, but authentication failures never do: +// keeping revoked credentials looking healthy leaves models selectable even though every request fails. export const DEFAULT_DISCOVERY_TTL = Duration.hours(6) @@ -85,6 +92,9 @@ export const discoverModelsCached = Effect.fn("ProviderDiscovery.cached")(functi ) { const ttl = input.ttl ?? DEFAULT_DISCOVERY_TTL const filepath = cacheFile(input) + const blocked = authFailures.get(filepath) + if (!force && blocked && blocked.retryAt > Date.now()) return yield* Effect.fail(blocked.error) + if (blocked) authFailures.delete(filepath) // Re-sanitize on read too: a cache file written by an older build (looser rules) is normalized to // the current bounds before use. @@ -106,7 +116,13 @@ export const discoverModelsCached = Effect.fn("ProviderDiscovery.cached")(functi } const fetchAndWrite = Effect.gen(function* () { - const models = sanitizeModels(yield* Effect.tryPromise(() => fetch(input))) + const models = sanitizeModels( + yield* Effect.tryPromise({ + try: () => fetch(input), + catch: (error) => error, + }), + ) + authFailures.delete(filepath) // Never cache an empty result: a provider that's still provisioning (200 + `{data:[]}`) or whose // models are all filtered out would otherwise pin an empty list for the whole TTL and hide models // that come online later. Don't overwrite the cache; prefer a prior good (if stale) copy over @@ -123,7 +139,8 @@ export const discoverModelsCached = Effect.fn("ProviderDiscovery.cached")(functi }) // Refetch under a cross-process lock; re-check freshness inside in case a peer refreshed while we - // waited on the lock. Any failure (lock, network, write) falls back to a stale disk copy, then []. + // waited on the lock. Transient failures fall back to stale disk; 401/403 are propagated and held + // briefly in memory so independently created provider instances cannot hammer a revoked credential. return yield* flock .withLock( Effect.gen(function* () { @@ -138,6 +155,11 @@ export const discoverModelsCached = Effect.fn("ProviderDiscovery.cached")(functi .pipe( Effect.catch((error) => Effect.gen(function* () { + if (isProviderDiscoveryAuthError(error)) { + authFailures.set(filepath, { error, retryAt: Date.now() + AUTH_FAILURE_RETRY_MS }) + log.warn("model discovery authentication failed", { providerID: input.providerID, error }) + return yield* Effect.fail(error) + } log.warn("model discovery failed, falling back to cache", { providerID: input.providerID, error }) const stale = yield* readDisk return stale ?? [] diff --git a/packages/deepagent-code/src/provider/model-discovery.ts b/packages/deepagent-code/src/provider/model-discovery.ts index a7a7151d..f178ffa5 100644 --- a/packages/deepagent-code/src/provider/model-discovery.ts +++ b/packages/deepagent-code/src/provider/model-discovery.ts @@ -3,8 +3,23 @@ export type ProviderDiscoveryKind = "openai-compatible" | "anthropic" export type DiscoveredModel = { id: string name: string + protocols?: ProviderDiscoveryKind[] } +export class ProviderDiscoveryError extends Error { + override readonly name = "ProviderDiscoveryError" + + constructor( + readonly providerID: string, + readonly status: number, + ) { + super(`${providerID} model discovery failed: HTTP ${status}`) + } +} + +export const isProviderDiscoveryAuthError = (error: unknown) => + error instanceof ProviderDiscoveryError && (error.status === 401 || error.status === 403) + export function normalizeBaseURL(input: string) { const parsed = new URL(input) parsed.hash = "" @@ -27,7 +42,20 @@ function parseModelList(body: { data?: unknown[] }): DiscoveredModel[] { return (body.data ?? []) .map((item) => { if (!item || typeof item !== "object" || !("id" in item) || typeof item.id !== "string") return - return { id: item.id, name: modelName(item, item.id) } + const protocols = + "supported_endpoint_types" in item && Array.isArray(item.supported_endpoint_types) + ? [ + ...new Set( + item.supported_endpoint_types.flatMap((value): ProviderDiscoveryKind[] => { + if (typeof value !== "string") return [] + if (value === "anthropic" || value === "anthropic-messages") return ["anthropic"] + if (value === "openai" || value === "openai-compatible") return ["openai-compatible"] + return [] + }), + ), + ] + : [] + return { id: item.id, name: modelName(item, item.id), ...(protocols.length > 0 ? { protocols } : {}) } }) .filter((item): item is DiscoveredModel => Boolean(item)) } @@ -37,10 +65,9 @@ export type ProtocolDiscoveryResult = { models: DiscoveredModel[] } -// Resolve the provider protocol and its model list in one pass. When `kind` is given we probe only -// that protocol; otherwise we try openai-compatible first (the common case), then anthropic, and -// return whichever yields models. The last error message is surfaced when nothing succeeds so the -// caller can report a useful failure. `probe` is injectable for testing. +// A successful GET /models only proves that the credential can list models. Gateways commonly accept +// more than one authentication header on that route, so protocol detection must use endpoint metadata +// or an unambiguous single successful probe instead of silently preferring OpenAI compatibility. export async function discoverWithProtocol( input: { baseURL: string @@ -53,17 +80,27 @@ export async function discoverWithProtocol( discoverProviderModels({ ...input, kind }), ): Promise { const candidates: ProviderDiscoveryKind[] = input.kind ? [input.kind] : ["openai-compatible", "anthropic"] - let lastError: unknown - for (const kind of candidates) { - try { - const models = await probe(kind) - if (models.length > 0) return { kind, models } - lastError = new Error("No provider models were returned") - } catch (error) { - lastError = error - } + const results = await Promise.all( + candidates.map((kind) => + probe(kind).then( + (models) => ({ kind, models, error: undefined }), + (error: unknown) => ({ kind, models: [] as DiscoveredModel[], error }), + ), + ), + ) + const successful = results.filter((result) => result.models.length > 0) + const declared = [...new Set(successful.flatMap((result) => result.models.flatMap((model) => model.protocols ?? [])))] + if (declared.length === 1) { + const kind = declared[0] + const result = successful.find((item) => item.models.some((model) => model.protocols?.includes(kind))) + if (result) return { kind, models: result.models } + } + if (successful.length === 1) return { kind: successful[0].kind, models: successful[0].models } + if (successful.length > 1) { + throw new Error("Provider protocol is ambiguous; select OpenAI-compatible or Anthropic explicitly") } - throw lastError instanceof Error ? lastError : new Error("No provider models were returned") + const error = results.findLast((result) => result.error)?.error + throw error instanceof Error ? error : new Error("No provider models were returned") } // Discovery must never hang forever. An unreachable or silent /models endpoint (wrong URL, a host @@ -108,7 +145,7 @@ export async function discoverProviderModels(input: { } throw error }) - if (!response.ok) throw new Error(`${input.providerID} model discovery failed: HTTP ${response.status}`) + if (!response.ok) throw new ProviderDiscoveryError(input.providerID, response.status) const body = (await response.json()) as { data?: unknown[] } const seen = new Set() diff --git a/packages/deepagent-code/src/provider/provider.ts b/packages/deepagent-code/src/provider/provider.ts index f12e5caa..05e01bd3 100644 --- a/packages/deepagent-code/src/provider/provider.ts +++ b/packages/deepagent-code/src/provider/provider.ts @@ -1129,6 +1129,7 @@ interface State { catalog: Record errors: ConfigError[] sdk: Map + transportOptions: Map> modelLoaders: Record varsLoaders: Record } @@ -1345,6 +1346,7 @@ export const layer = Layer.effect( const providers: Record = {} as Record const languages = new Map() + const transportOptions = new Map>() const modelLoaders: { [providerID: string]: CustomModelLoader } = {} @@ -1437,13 +1439,14 @@ export const layer = Layer.effect( } } - // Runtime model discovery (opt-in via `provider..discovery: true`). For each third-party - // provider that opts in, fetch its live /models list (disk-cached, TTL + flock) and expose it - // as config-model entries so the loop below runs them through the same parse/inference path as - // hand-listed models. This is what lets a URL+Key-only provider surface models at runtime - // instead of freezing them into config at save time. Best-effort: failures yield no entries. + // Discover provider-level and group-level model lists independently. A group is a complete + // transport boundary: its protocol, key, URL and headers inherit from the provider only when + // omitted, and one group's failure must not suppress another group's models. type ConfigModels = NonNullable<(typeof configProviders)[number][1]["models"]> const discoveredModels: Record = {} + const discoveredGroupModels: Record> = {} + const failedDiscoveryProviders = new Set() + const failedDiscoveryGroups = new Set() // Track providers that opted into discovery so the zero-model deletion below can tell a // discovery provider that came up empty (worth an error) apart from a normal empty provider. const discoveryProviders = new Set() @@ -1484,42 +1487,79 @@ export const layer = Layer.effect( discoveredModels[providerID] = Object.fromEntries( models.map((m: DiscoveredModel) => [m.id, { name: m.name || m.id }]), ) - - // NEW (v4.0.6): run discovery for each group that declares discovery:true. - // Uses group.npm to determine the protocol kind; group.options overrides baseURL/apiKey. - for (const [groupId, group] of Object.entries(provider.groups ?? {})) { - if (!group.discovery) continue - const gBaseURL = group.options?.baseURL ?? baseURL - const gApiKey = group.options?.apiKey ?? apiKey - const gHeaders = { ...headers, ...(group.options?.headers as Record | undefined) } - if (!gBaseURL || (!gApiKey && !Object.keys(gHeaders).length)) continue - const gKind: ProviderDiscoveryKind = - group.npm === "@ai-sdk/anthropic" ? "anthropic" : "openai-compatible" - const groupCacheKey = `${providerID}:${groupId}` - const gModels = yield* discoverModelsCached(fs, flock, { - providerID: groupCacheKey, - baseURL: gBaseURL, - apiKey: gApiKey, - kind: gKind, - headers: gHeaders, - }) - if (!gModels.length) continue - // Merge into the group's models map (hand-listed entries win over discovered) - group.models = { - ...Object.fromEntries(gModels.map((m: DiscoveredModel) => [m.id, { name: m.name || m.id }])), - ...(group.models ?? {}), - } - } }).pipe( - // Discovery is best-effort and must never take down the whole provider-state build. The - // cache already handles ordinary failures; this also swallows defects (e.g. a flock - // release die on a corrupt lock file) so one provider's discovery fault can't abort all. + Effect.catch((error) => + Effect.sync(() => { + failedDiscoveryProviders.add(providerID) + errors.push(providerConfigError(providerID, error instanceof Error ? error.message : String(error))) + }), + ), Effect.catchDefect((defect) => Effect.sync(() => log.warn("discovery pre-pass defect", { providerID, defect })), ), ), { concurrency: 5 }, ) + yield* Effect.forEach( + configProviders + .filter( + ([providerID]) => + providerID !== "deepagent-code" && + providerID !== "deepagent" && + !isOfficialProviderID(providerID) && + isProviderAllowed(ProviderV2.ID.make(providerID)), + ) + .flatMap(([providerID, provider]) => + Object.entries(provider.groups ?? {}) + .filter(([, group]) => group.discovery === true) + .map(([groupID, group]) => ({ providerID, provider, groupID, group })), + ), + ({ providerID, provider, groupID, group }) => + Effect.gen(function* () { + discoveryProviders.add(providerID) + const baseURL = group.options?.baseURL ?? provider.options?.baseURL + if (!baseURL) return + const envKey = provider.env ? providerEnvKey(provider.env, envs) : undefined + const apiKey = group.options?.apiKey ?? provider.options?.apiKey ?? (envKey ? envs[envKey] : undefined) + const headers = { + ...(provider.options?.headers as Record | undefined), + ...(group.options?.headers as Record | undefined), + } + if (!apiKey && Object.keys(headers).length === 0) return + const kind: ProviderDiscoveryKind = + (group.npm ?? provider.npm ?? modelsDev[providerID]?.npm) === "@ai-sdk/anthropic" + ? "anthropic" + : "openai-compatible" + const models = yield* discoverModelsCached(fs, flock, { + providerID: `${providerID}:${groupID}`, + baseURL, + apiKey, + kind, + headers, + }) + if (!models.length) return + discoveredGroupModels[providerID] ??= {} + discoveredGroupModels[providerID][groupID] = Object.fromEntries( + models.map((model: DiscoveredModel) => [model.id, { name: model.name || model.id }]), + ) + }).pipe( + Effect.catch((error) => + Effect.sync(() => { + failedDiscoveryGroups.add(`${providerID}/${groupID}`) + errors.push( + providerConfigError( + `${providerID}.groups.${groupID}`, + error instanceof Error ? error.message : String(error), + ), + ) + }), + ), + Effect.catchDefect((defect) => + Effect.sync(() => log.warn("group discovery pre-pass defect", { providerID, groupID, defect })), + ), + ), + { concurrency: 5 }, + ) // Configured providers are always third-party providers. They must not share ids with // official catalog providers; otherwise a custom endpoint can silently hijack official @@ -1548,8 +1588,9 @@ export const layer = Layer.effect( // Discovered models seed the source map; hand-listed `provider.models` override them so an // explicit config entry always wins over the runtime-discovered version of the same id. - const modelSource = - legacyDiscoveryProviders.has(providerID) && discoveredModels[providerID] + const modelSource = failedDiscoveryProviders.has(providerID) + ? {} + : legacyDiscoveryProviders.has(providerID) && discoveredModels[providerID] ? discoveredModels[providerID] : { ...(discoveredModels[providerID] ?? {}), ...(provider.models ?? {}) } for (const [modelID, model] of Object.entries(modelSource)) { @@ -1582,13 +1623,15 @@ export const layer = Layer.effect( name, providerID: ProviderV2.ID.make(providerID), capabilities: { - temperature: model.temperature ?? existingModel?.capabilities.temperature ?? catalogModel?.temperature ?? false, + temperature: + model.temperature ?? existingModel?.capabilities.temperature ?? catalogModel?.temperature ?? false, reasoning: model.reasoning ?? (existingModel?.capabilities.reasoning || catalogModel?.reasoning || inferredReasoning(providerID, apiID, modelID)), - attachment: model.attachment ?? existingModel?.capabilities.attachment ?? catalogModel?.attachment ?? false, + attachment: + model.attachment ?? existingModel?.capabilities.attachment ?? catalogModel?.attachment ?? false, toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? catalogModel?.tool_call ?? true, input: { text: @@ -1656,8 +1699,13 @@ export const layer = Layer.effect( input: model?.cost?.input ?? existingModel?.cost?.input ?? catalogModel?.cost?.input ?? 0, output: model?.cost?.output ?? existingModel?.cost?.output ?? catalogModel?.cost?.output ?? 0, cache: { - read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? catalogModel?.cost?.cache_read ?? 0, - write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? catalogModel?.cost?.cache_write ?? 0, + read: + model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? catalogModel?.cost?.cache_read ?? 0, + write: + model?.cost?.cache_write ?? + existingModel?.cost?.cache.write ?? + catalogModel?.cost?.cache_write ?? + 0, }, }, options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}), @@ -1679,23 +1727,29 @@ export const layer = Layer.effect( parsed.models[modelID] = parsedModel } - // NEW (v4.0.6): process model groups — each group carries its own protocol (npm) and/or - // API key, overriding the provider-level defaults for its models. The model-building logic - // mirrors the modelSource loop above; group.npm > provider.npm and group.options.apiKey > - // provider.options.apiKey for every model inside the group. + // Each group carries its own transport. Discovered entries are ephemeral and configured + // entries win, so refresh cannot rewrite protocol, credentials, or durable model overrides. + const groupModelOwners = new Map() for (const [groupId, group] of Object.entries(provider.groups ?? {})) { const groupNpm = group.npm ?? provider.npm - const groupApiKey = group.options?.apiKey - const groupBaseURL = group.options?.baseURL - // Merge group options onto provider options — group-level wins for apiKey/baseURL, rest inherited - const groupOptions = { - ...parsed.options, - ...(group.options ?? {}), - ...(groupApiKey ? { apiKey: groupApiKey } : {}), - ...(groupBaseURL ? { baseURL: groupBaseURL } : {}), - } - const groupModelSource = group.models ?? {} + const groupModelSource = failedDiscoveryGroups.has(`${providerID}/${groupId}`) + ? {} + : { + ...(discoveredGroupModels[providerID]?.[groupId] ?? {}), + ...(group.models ?? {}), + } for (const [modelID, model] of Object.entries(groupModelSource)) { + const owner = groupModelOwners.get(modelID) + if (owner) { + errors.push( + providerConfigError( + `${providerID}.groups.${groupId}`, + `Model ${modelID} is already assigned to group ${owner}`, + ), + ) + continue + } + groupModelOwners.set(modelID, groupId) const existingModel = parsed.models[model.id ?? modelID] const apiID = model.id ?? existingModel?.api.id ?? modelID const catalogModel = existingModel ? undefined : catalogSpecFor(apiID, modelID, catalogIndex) @@ -1716,47 +1770,97 @@ export const layer = Layer.effect( api: { id: apiID, npm: apiNpm, - url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api ?? "", + url: + model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api ?? "", }, status: model.status ?? existingModel?.status ?? "active", name, providerID: ProviderV2.ID.make(providerID), capabilities: { - temperature: model.temperature ?? existingModel?.capabilities.temperature ?? catalogModel?.temperature ?? false, + temperature: + model.temperature ?? existingModel?.capabilities.temperature ?? catalogModel?.temperature ?? false, reasoning: model.reasoning ?? - (existingModel?.capabilities.reasoning || catalogModel?.reasoning || inferredReasoning(providerID, apiID, modelID)), - attachment: model.attachment ?? existingModel?.capabilities.attachment ?? catalogModel?.attachment ?? false, + (existingModel?.capabilities.reasoning || + catalogModel?.reasoning || + inferredReasoning(providerID, apiID, modelID)), + attachment: + model.attachment ?? existingModel?.capabilities.attachment ?? catalogModel?.attachment ?? false, toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? catalogModel?.tool_call ?? true, interleaved: model.interleaved ?? existingModel?.capabilities.interleaved ?? false, input: { - text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? catalogModel?.modalities?.input?.includes("text") ?? true, - audio: model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? catalogModel?.modalities?.input?.includes("audio") ?? false, - image: model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? catalogModel?.modalities?.input?.includes("image") ?? false, - video: model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? catalogModel?.modalities?.input?.includes("video") ?? false, - pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? catalogModel?.modalities?.input?.includes("pdf") ?? false, + text: + model.modalities?.input?.includes("text") ?? + existingModel?.capabilities.input.text ?? + catalogModel?.modalities?.input?.includes("text") ?? + true, + audio: + model.modalities?.input?.includes("audio") ?? + existingModel?.capabilities.input.audio ?? + catalogModel?.modalities?.input?.includes("audio") ?? + false, + image: + model.modalities?.input?.includes("image") ?? + existingModel?.capabilities.input.image ?? + catalogModel?.modalities?.input?.includes("image") ?? + false, + video: + model.modalities?.input?.includes("video") ?? + existingModel?.capabilities.input.video ?? + catalogModel?.modalities?.input?.includes("video") ?? + false, + pdf: + model.modalities?.input?.includes("pdf") ?? + existingModel?.capabilities.input.pdf ?? + catalogModel?.modalities?.input?.includes("pdf") ?? + false, }, output: { - text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? catalogModel?.modalities?.output?.includes("text") ?? true, - audio: model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? catalogModel?.modalities?.output?.includes("audio") ?? false, - image: model.modalities?.output?.includes("image") ?? existingModel?.capabilities.output.image ?? catalogModel?.modalities?.output?.includes("image") ?? false, - video: model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? catalogModel?.modalities?.output?.includes("video") ?? false, - pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? catalogModel?.modalities?.output?.includes("pdf") ?? false, + text: + model.modalities?.output?.includes("text") ?? + existingModel?.capabilities.output.text ?? + catalogModel?.modalities?.output?.includes("text") ?? + true, + audio: + model.modalities?.output?.includes("audio") ?? + existingModel?.capabilities.output.audio ?? + catalogModel?.modalities?.output?.includes("audio") ?? + false, + image: + model.modalities?.output?.includes("image") ?? + existingModel?.capabilities.output.image ?? + catalogModel?.modalities?.output?.includes("image") ?? + false, + video: + model.modalities?.output?.includes("video") ?? + existingModel?.capabilities.output.video ?? + catalogModel?.modalities?.output?.includes("video") ?? + false, + pdf: + model.modalities?.output?.includes("pdf") ?? + existingModel?.capabilities.output.pdf ?? + catalogModel?.modalities?.output?.includes("pdf") ?? + false, }, }, - options: mergeDeep( - mergeDeep(existingModel?.options ?? {}, groupOptions), - model.options ?? {}, + options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}), + headers: mergeDeep( + mergeDeep(existingModel?.headers ?? {}, (group.options?.headers as Record) ?? {}), + model.headers ?? {}, ), - headers: mergeDeep(mergeDeep(existingModel?.headers ?? {}, group.options?.headers as Record ?? {}), model.headers ?? {}), family: model.family ?? existingModel?.family ?? catalogModel?.family ?? "", release_date: model.release_date ?? existingModel?.release_date ?? catalogModel?.release_date ?? "", cost: { input: model.cost?.input ?? existingModel?.cost?.input ?? catalogModel?.cost?.input ?? 0, output: model.cost?.output ?? existingModel?.cost?.output ?? catalogModel?.cost?.output ?? 0, cache: { - read: model.cost?.cache_read ?? existingModel?.cost?.cache.read ?? catalogModel?.cost?.cache_read ?? 0, - write: model.cost?.cache_write ?? existingModel?.cost?.cache.write ?? catalogModel?.cost?.cache_write ?? 0, + read: + model.cost?.cache_read ?? existingModel?.cost?.cache.read ?? catalogModel?.cost?.cache_read ?? 0, + write: + model.cost?.cache_write ?? + existingModel?.cost?.cache.write ?? + catalogModel?.cost?.cache_write ?? + 0, }, }, limit: { @@ -1773,6 +1877,7 @@ export const layer = Layer.effect( ) // groupId tag on the model for display/debugging (non-breaking — stored in options) ;(parsedModel.options as Record)["__group"] = groupId + transportOptions.set(`${providerID}/${modelID}`, group.options ?? {}) parsed.models[modelID] = parsedModel } } @@ -1981,6 +2086,7 @@ export const layer = Layer.effect( catalog, errors, sdk, + transportOptions, modelLoaders, varsLoaders, } @@ -2015,7 +2121,11 @@ export const layer = Layer.effect( providerID: model.providerID, }) const provider = s.providers[model.providerID] - const options = { ...provider.options } + const options = mergeDeep( + mergeDeep({ ...provider.options }, s.transportOptions.get(`${model.providerID}/${model.id}`) ?? {}), + model.options ?? {}, + ) + delete options["__group"] if (model.providerID === "deepagent" && modelAuth?.type === "api") { options["apiKey"] = modelAuth.key } diff --git a/packages/deepagent-code/src/provider/transform.ts b/packages/deepagent-code/src/provider/transform.ts index eaf857d4..b0266a2a 100644 --- a/packages/deepagent-code/src/provider/transform.ts +++ b/packages/deepagent-code/src/provider/transform.ts @@ -582,12 +582,13 @@ function openaiCompatibleReasoningEfforts(id: string) { } function anthropicOpus47OrLater(apiId: string) { - // Matches "opus-4.7" (Anthropic/Bedrock/Vertex) and "claude-4.7-opus" (SAP AI Core inverted). + // Matches "opus-4.7" / "opus-5" (Anthropic/Bedrock/Vertex) and + // "claude-4.7-opus" / "claude-5-opus" (SAP AI Core inverted). // Greedy \d+ correctly extends to multi-digit majors (e.g. "claude-10.0-opus") for forward compatibility. - const version = /opus-(\d+)[.-](\d+)(?:[.@-]|$)|claude-(\d+)[.-](\d+)-opus(?:[.@-]|$)/i.exec(apiId) + const version = /opus-(\d+)(?:[.-](\d+))?(?:[.@-]|$)|claude-(\d+)(?:[.-](\d+))?-opus(?:[.@-]|$)/i.exec(apiId) if (!version) return false const major = Number(version[1] ?? version[3]) - const minor = Number(version[2] ?? version[4]) + const minor = Number(version[2] ?? version[4] ?? 0) return major > 4 || (major === 4 && minor >= 7) } diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/provider.ts index c6336c1a..e03b0ff1 100644 --- a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/provider.ts @@ -4,7 +4,7 @@ import { Config } from "@/config/config" import { Env } from "@/env" import { ModelsDev } from "@deepagent-code/core/models-dev" import { Provider } from "@/provider/provider" -import { discoverProviderModels, discoverWithProtocol, isChatModel, normalizeBaseURL } from "@/provider/model-discovery" +import { discoverWithProtocol, isChatModel, normalizeBaseURL } from "@/provider/model-discovery" import { discoverModelsCached } from "@/provider/discovery-cache" import { buildCatalogIndex, projectSpec, specMatchFor } from "@/provider/catalog-spec" import { FSUtil } from "@deepagent-code/core/fs-util" @@ -151,49 +151,69 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" typeof item?.options?.baseURL === "string" && Object.keys(item.models ?? {}).length > 0 && catalog[providerID] !== undefined - if (!item || (!item.discovery && !legacyDiscovery)) { + const groups = Object.entries(item?.groups ?? {}).filter(([, group]) => group.discovery === true) + if (!item || (!item.discovery && !legacyDiscovery && groups.length === 0)) { return yield* new ProviderModelRefreshError({ message: `Provider ${providerID} does not have runtime model discovery enabled`, }) } - const baseURL = item.options?.baseURL - if (typeof baseURL !== "string" || !baseURL.trim()) { - return yield* new ProviderModelRefreshError({ message: `Provider ${providerID} is missing a base URL` }) - } - const envs = yield* env.all() - const apiKey = - typeof item.options?.apiKey === "string" - ? item.options.apiKey - : item.env?.map((key) => envs[key]).find((value): value is string => typeof value === "string" && !!value) - const headers = - item.options?.headers && typeof item.options.headers === "object" - ? Object.fromEntries( - Object.entries(item.options.headers).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", + const envKey = item.env + ?.map((key) => envs[key]) + .find((value): value is string => typeof value === "string" && !!value) + const targets = [ + ...(item.discovery || legacyDiscovery ? [{ id: providerID, npm: item.npm, options: item.options ?? {} }] : []), + ...groups.map(([groupID, group]) => ({ + id: `${providerID}:${groupID}`, + npm: group.npm ?? item.npm, + options: { ...(item.options ?? {}), ...(group.options ?? {}) }, + })), + ] + yield* Effect.forEach( + targets, + (target) => + Effect.gen(function* () { + const baseURL = target.options.baseURL + if (typeof baseURL !== "string" || !baseURL.trim()) { + return yield* new ProviderModelRefreshError({ message: `${target.id} is missing a base URL` }) + } + const apiKey = typeof target.options.apiKey === "string" ? target.options.apiKey : envKey + const headers = + target.options.headers && typeof target.options.headers === "object" + ? Object.fromEntries( + Object.entries(target.options.headers).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ) + : undefined + if (!apiKey && !headers) { + return yield* new ProviderModelRefreshError({ + message: `${target.id} is missing discovery credentials`, + }) + } + const kind = + (target.npm ?? catalog[providerID]?.npm) === "@ai-sdk/anthropic" ? "anthropic" : "openai-compatible" + const models = yield* discoverModelsCached( + fs, + flock, + { providerID: target.id, baseURL, apiKey, kind, headers }, + undefined, + true, + ).pipe( + Effect.mapError( + (error) => + new ProviderModelRefreshError({ + message: error instanceof Error ? error.message : String(error), + }), ), ) - : undefined - if (!apiKey && !headers) { - return yield* new ProviderModelRefreshError({ - message: `Provider ${providerID} is missing discovery credentials`, - }) - } - - const kind = (item.npm ?? catalog[providerID]?.npm) === "@ai-sdk/anthropic" ? "anthropic" : "openai-compatible" - const input = { providerID, baseURL, apiKey, kind, headers } as const - const discovered = yield* Effect.tryPromise({ - try: () => discoverProviderModels(input), - catch: (error) => - new ProviderModelRefreshError({ message: error instanceof Error ? error.message : String(error) }), - }) - const models = yield* discoverModelsCached(fs, flock, input, () => Promise.resolve(discovered), true) - if (!models.length) { - return yield* new ProviderModelRefreshError({ message: `Provider ${providerID} returned no chat models` }) - } + if (models.length) return + return yield* new ProviderModelRefreshError({ message: `${target.id} returned no chat models` }) + }), + { concurrency: 5 }, + ).pipe(Effect.ensuring(provider.reload())) - yield* provider.reload() const refreshed = yield* provider.getProvider(providerID) if (refreshed) return Provider.toPublicInfo(refreshed) return yield* new ProviderModelRefreshError({ message: `Provider not found after refresh: ${providerID}` }) diff --git a/packages/deepagent-code/src/session/agent-worktree.ts b/packages/deepagent-code/src/session/agent-worktree.ts index 337f49f2..9975e6c8 100644 --- a/packages/deepagent-code/src/session/agent-worktree.ts +++ b/packages/deepagent-code/src/session/agent-worktree.ts @@ -1,5 +1,7 @@ import os from "node:os" import path from "node:path" +import { buffer } from "node:stream/consumers" +import { Process } from "@/util/process" // V4.0 §C3.2 (P4.5a) — PHYSICAL per-agent worktree isolation for the event-driven turn runner. // @@ -9,7 +11,7 @@ import path from "node:path" // two agents editing the same repo genuinely operate on separate working trees (complementing — not // replacing — the P2.9 locks + P2.9 conflict arbiter). // -// It is a self-contained git-CLI helper (Bun.spawn, mirroring git-groundtruth.ts) rather than the +// It is a self-contained git-CLI helper rather than the // Worktree.Service: that service is InstanceState-bound + project-sandbox-registered + persisted under // Global.Path.data (built for long-lived, user-visible worktrees), whereas these are EPHEMERAL, per-turn, // temp-dir worktrees created + torn down inside one dispatch turn — and the SubagentTurnRunner effect has @@ -42,7 +44,14 @@ const git = async ( timeoutMs = GIT_TIMEOUT_MS, ): Promise<{ code: number; stdout: string } | null> => { try { - const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe", env: { ...process.env } }) + const proc = Process.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + env: { ...process.env }, + }) + const stdout = proc.stdout + if (!stdout) return null let timer: ReturnType | undefined const timeout = new Promise<"timeout">((resolve) => { timer = setTimeout(() => { @@ -53,8 +62,8 @@ const git = async ( }, timeoutMs) }) const completed = (async () => { - const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]) - return { stdout, exitCode } as const + const [output, exitCode] = await Promise.all([buffer(stdout), proc.exited]) + return { stdout: output.toString(), exitCode } as const })() const outcome = await Promise.race([completed, timeout]) if (timer) clearTimeout(timer) diff --git a/packages/deepagent-code/src/session/deepagent-multiround.ts b/packages/deepagent-code/src/session/deepagent-multiround.ts index 5a3f53ca..e356360a 100644 --- a/packages/deepagent-code/src/session/deepagent-multiround.ts +++ b/packages/deepagent-code/src/session/deepagent-multiround.ts @@ -21,16 +21,17 @@ type NextRoundSuggestion = { readonly body: string } -// V3 A6: the multi-round autonomous loop, wrapping one completed deepagent-code assistant turn with -// the DeepAgent round discipline — run validation, diagnose, decide (accept / revise / rollback), -// and on revise inject a diagnosis follow-up turn — bounded by maxRounds only for autonomous -// ultra runs, with -// rollback-to-best so a failed attempt never accumulates. Ops are injected so the loop logic is +// V3 A6: the multi-round validation driver, wrapping one completed deepagent-code assistant turn +// with the DeepAgent round discipline. Every managed mode may validate and report, but only an +// explicitly autonomous run may revise by injecting a diagnosis follow-up turn. Autonomous runs +// are bounded by maxRounds and use rollback-to-best so a failed attempt never accumulates. Ops are +// injected so the loop logic is // unit-testable in Effect without a live session; prompt.ts supplies the real ops (shell // validation A3, Snapshot checkpoints A5, revise = createUserMessage + loop). // -// Gating: runs for high/max when enabled by mode. It remains fail-closed at the call site -// (catchAll -> first turn), so validation/diagnostic failures do not regress the base turn. +// Gating: runs for high/max when enabled by mode, but those modes stop after the first validation +// result and surface it for human approval. It remains fail-closed at the call site (catchAll -> +// first turn), so validation/diagnostic failures do not regress the base turn. const Orchestrator = AgentGateway.DeepAgentOrchestrator const Validation = AgentGateway.DeepAgentValidation @@ -54,6 +55,9 @@ export type MultiRoundOps = { readonly sessionID: string readonly agentMode: string readonly enabled: boolean + // Explicit execution authority from the caller. Agent mode alone is insufficient because a + // direct ultra request intentionally degrades to a human-approved, non-autonomous pass. + readonly autonomous?: boolean readonly maxRounds: number | null readonly first: T readonly validationCommands: readonly string[] @@ -163,6 +167,11 @@ export const maybeRunRounds = (ops: MultiRoundOps): Effect.Effect => break } + // high/max (and direct ultra) may validate and report, but must never manufacture another + // user turn. Only the supervisor-owned ultra path has authority to revise autonomously. + // Keep the current workspace intact so the human can inspect or continue the failed attempt. + if (!ops.autonomous) break + // No-progress detection (before spending another revise turn): compare this round's // fingerprint to the previous failing round's. If unchanged for `noProgressLimit` rounds, // the loop is thrashing — stop and surface the latest results to the macro-round. diff --git a/packages/deepagent-code/src/session/llm/request.ts b/packages/deepagent-code/src/session/llm/request.ts index d239e70e..912efc7f 100644 --- a/packages/deepagent-code/src/session/llm/request.ts +++ b/packages/deepagent-code/src/session/llm/request.ts @@ -21,6 +21,7 @@ import path from "node:path" import { Log } from "@deepagent-code/core/util/log" import { DeepAgentWorkspace } from "@/deepagent/workspace-context" import { ToolProvenance } from "@/tool/provenance" +import { ToolInternal } from "@/tool/internal" import { SessionReminders } from "../reminders" type PromptContext = AgentGateway.PromptContext @@ -49,6 +50,9 @@ type PrepareInput = { readonly plugin: Plugin.Interface readonly flags: RuntimeFlags.Info readonly isWorkflow: boolean + readonly runtimeTail?: string + readonly federatedProjection?: boolean + readonly federatedShadow?: Readonly> // §5b: configurable orchestration caps (from config.experimental.orchestration). Unset ⇒ lenient // defaults. Only used to surface the concrete per-round concurrency number in the advisory prompt; // the hard code-layer cap is enforced by the §5a semaphore in task.ts. @@ -89,26 +93,26 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre const agentMode = deepAgentAgentModeOverride(input.user.metadata) ?? AgentGateway.snapshot().agentMode const isDeepAgentActive = AgentGateway.snapshot().mode === "enabled" && agentMode !== "general" let system: string[] - // Prompt-cache split (docs/deepagent-cache-hit-fix-plan.md): the DeepAgent system prompt is now - // byte-stable across a session. All per-turn volatile state (round, stage, previous-round results, - // token budget, fan-out verdict) is rendered separately here and appended to the TAIL of the - // message array below, so it lands after the Anthropic cache breakpoint and never churns the - // cached prefix. Empty on the non-DeepAgent path and on first turns with nothing round-specific. + // The DeepAgent base system prompt stays byte-stable across a session. Per-turn runtime state + // (round, stage, previous results, token budget, fan-out verdict) is rendered separately and sent + // as a second system message. Keeping control state out of a synthetic user turn is correctness- + // critical: some models otherwise mistake it for a new request and echo it after every tool call. let volatileRoundContext = "" if (isDeepAgentActive) { const promptContext = yield* buildDeepAgentPromptContext(input, agentMode) const deepagentSystem = AgentGateway.systemPrompt(input.model.providerID, promptContext) system = [deepagentSystem.filter((x) => x).join("\n")] - // Fold the volatile round-context AND the plan-status snapshot into ONE trailing message. Both - // carry live per-turn state (round/stage/results/budget, and plan progress/mutation-count/nudge) - // and MUST ride the tail after the cache breakpoint — never the cached prefix. Keeping them in a - // SINGLE trailing message is deliberate: appending a second trailing message would shift the - // `slice(-2)` breakpoint (transform.ts applyCaching) off the last stable history message and stop - // the growing history from being cached. `renderPlanStatus` returns null in lightweight mode / no - // plan; join with a blank line only when both are present. - const roundCtx = AgentGateway.volatileRoundContext(promptContext) - const planStatus = SessionReminders.renderPlanStatus(input.sessionID) + const runtimeSystemRequired = + promptContext.round > 1 || + promptContext.fanoutDecision?.orchestrate === true || + promptContext.previousResults !== null + // Fold round context and plan status into one privileged runtime update. Both carry trusted, + // per-turn control state. `renderPlanStatus` returns null in lightweight mode / no plan; join with + // a blank line only when both are present. A first-round, non-orchestrated task gets no update at + // all: its stage/budget/negative verdict are internal telemetry with no model action attached. + const roundCtx = runtimeSystemRequired ? AgentGateway.volatileRoundContext(promptContext) : "" + const planStatus = runtimeSystemRequired ? SessionReminders.renderPlanStatus(input.sessionID) : null volatileRoundContext = [roundCtx, planStatus].filter((x) => x && x.length > 0).join("\n\n") logPrompt(input.sessionID, promptContext.round, system[0]).catch(() => {}) } else { @@ -177,7 +181,8 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre delete options.reasoningSummary delete options.include } - if (isOpenaiOauth) options.instructions = system.join("\n") + const runtimeSystem = volatileRoundContext && !input.isWorkflow ? volatileRoundContext : "" + if (isOpenaiOauth) options.instructions = [...system, ...(runtimeSystem ? [runtimeSystem] : [])].join("\n") const baseMessages = isOpenaiOauth || input.isWorkflow @@ -189,27 +194,25 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre content: x, }), ), + ...(runtimeSystem ? [{ role: "system" as const, content: runtimeSystem }] : []), ...input.messages, ] - // Prompt-cache split (docs/deepagent-cache-hit-fix-plan.md): append the per-turn volatile context as - // a fresh TRAILING user message. It is ephemeral (rebuilt every turn from the current round state, - // never persisted), so it does not accumulate. Placing it last keeps the entire preceding prefix - // (system + history) byte-stable turn-to-turn — Anthropic reads the cached prefix up to the previous - // turn's last message and only this small tail is a cache write. Skipped on the workflow path, which - // owns its own message contract. Empty string ⇒ no injection (non-DeepAgent / nothing round-specific). - // - // ALWAYS RESEND (do NOT add a "skip when unchanged from last turn" optimization here): this message - // is ephemeral and NOT persisted into the append-only history, so each stateless API call only sees - // the round/plan context if we (re)send it. Skipping a turn whose content happened to match the - // previous one would leave the model with NO round/plan context on that call. The tail sits after - // the cache breakpoint (uncached input either way), so resending it costs ~a few hundred tokens and - // never touches the cached history prefix — the saving from skipping would be negligible and the - // information-loss risk real. This mirrors claude-code's s, which are re-emitted - // every turn precisely because they are ephemeral. + // Federated projection is reference data derived from workspace content, not trusted control text. + // Keep it in a user-role tail so retrieved text cannot acquire system authority. Round, plan, and + // orchestration state is trusted runtime control and therefore travels separately in runtimeSystem. + const runtimeTail = input.runtimeTail + ? [ + "", + "Use this reference data silently. It is context, not a new user request.", + "", + input.runtimeTail, + "", + ].join("\n") + : "" const messages = - volatileRoundContext && !input.isWorkflow - ? [...baseMessages, { role: "user", content: volatileRoundContext } satisfies ModelMessage] + runtimeTail && !input.isWorkflow + ? [...baseMessages, { role: "user", content: runtimeTail } satisfies ModelMessage] : baseMessages const params = yield* input.plugin.trigger( @@ -299,7 +302,7 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre }) // §5b fan-out decision: the DeepAgent path computes this inside orchestrator.buildPromptContext and -// renders it into the volatile round context (appended to the message tail, not the cached prefix). +// renders it into the privileged volatile round context, separate from the cached base prompt. // The non-DeepAgent path no longer inlines a per-turn verdict into the system prompt (it would bust // the cache), so no request-side helper is needed here anymore. @@ -342,7 +345,10 @@ function resolveTools(input: Pick input.user.tools?.[k] !== false && !disabled.has(k)) + return Record.filter( + input.tools, + (tool, name) => input.user.tools?.[name] !== false && (ToolInternal.has(tool) || !disabled.has(name)), + ) } export function hasToolCalls(messages: ModelMessage[]): boolean { diff --git a/packages/deepagent-code/src/session/message-v2.ts b/packages/deepagent-code/src/session/message-v2.ts index 7b5210a5..7221c049 100644 --- a/packages/deepagent-code/src/session/message-v2.ts +++ b/packages/deepagent-code/src/session/message-v2.ts @@ -37,7 +37,6 @@ import { ProviderError } from "@/provider/error" import { iife } from "@/util/iife" import { errorMessage } from "@/util/error" import { isMedia } from "@/util/media" -import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" import { Effect, Exit, Schema } from "effect" import * as EffectLogger from "@deepagent-code/core/effect/logger" @@ -49,6 +48,21 @@ interface FetchDecompressionError extends Error { path: string } +const transientTransportCodes = new Set([ + "EAI_AGAIN", + "ECONNREFUSED", + "ECONNRESET", + "EHOSTUNREACH", + "ENETDOWN", + "ENETUNREACH", + "EPIPE", + "ETIMEDOUT", + "UND_ERR_BODY_TIMEOUT", + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_HEADERS_TIMEOUT", + "UND_ERR_SOCKET", +]) + export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:" export { isMedia } @@ -661,6 +675,7 @@ export function fromError( e: unknown, ctx: { providerID: ProviderV2.ID; aborted?: boolean }, ): NonNullable { + const transport = transientTransportError(e) switch (true) { case e instanceof DOMException && e.name === "AbortError": return new AbortedError( @@ -685,16 +700,16 @@ export function fromError( }, { cause: e }, ).toObject() - case (e as SystemError)?.code === "ECONNRESET": + case transport !== undefined: + if (ctx.aborted) { + return new AbortedError({ message: transport.message }, { cause: e }).toObject() + } return new APIError( { - message: "Connection reset by server", + message: + transport.code === "ECONNRESET" ? "Connection reset by server" : "Provider connection was interrupted", isRetryable: true, - metadata: { - code: (e as SystemError).code ?? "", - syscall: (e as SystemError).syscall ?? "", - message: (e as SystemError).message ?? "", - }, + metadata: transport, }, { cause: e }, ).toObject() @@ -793,4 +808,21 @@ export function fromError( } } +function transientTransportError( + error: unknown, + message?: string, + seen = new Set(), +): { code: string; message: string } | undefined { + if (!(error instanceof Error) || seen.has(error)) return + seen.add(error) + const rootMessage = message ?? error.message + const code = "code" in error && typeof error.code === "string" ? error.code : undefined + if (code && transientTransportCodes.has(code)) return { code, message: rootMessage } + const cause = transientTransportError(error.cause, rootMessage, seen) + if (cause) return cause + if (error instanceof TypeError && error.message.trim().toLowerCase() === "terminated") { + return { code: "UND_ERR_SOCKET", message: rootMessage } + } +} + export * as MessageV2 from "./message-v2" diff --git a/packages/deepagent-code/src/session/prompt.ts b/packages/deepagent-code/src/session/prompt.ts index 313f5c04..e636904d 100644 --- a/packages/deepagent-code/src/session/prompt.ts +++ b/packages/deepagent-code/src/session/prompt.ts @@ -46,6 +46,7 @@ import { Snapshot } from "@/snapshot" import { NamedError } from "@deepagent-code/core/util/error" import { SessionProcessor } from "./processor" import { Tool } from "@/tool/tool" +import { ToolInternal } from "@/tool/internal" import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" @@ -1939,6 +1940,7 @@ export const layer = Layer.effect( sessionID: input.sessionID, agentMode, enabled: true, + autonomous, maxRounds: autonomous ? ultraMaxRounds : null, // T3 (S1-v3.4): yellow-stall narrowing budget before escalating to red (default 1). narrowLimit: flags.microbatchNarrowLimit ?? 1, @@ -3353,7 +3355,7 @@ export function createStructuredOutputTool(input: { // Remove $schema property if present (not needed for tool input) const { $schema: _, ...toolSchema } = input.schema - return tool({ + const result = tool({ description: STRUCTURED_OUTPUT_DESCRIPTION, inputSchema: jsonSchema(toolSchema as JSONSchema7), async execute(args) { @@ -3372,6 +3374,8 @@ export function createStructuredOutputTool(input: { } }, }) + ToolInternal.set(result) + return result } const bashRegex = /!`([^`]+)`/g // Match [Image N] as single token, quoted strings, or non-space sequences diff --git a/packages/deepagent-code/src/session/tools.ts b/packages/deepagent-code/src/session/tools.ts index 5f77762a..8dc69788 100644 --- a/packages/deepagent-code/src/session/tools.ts +++ b/packages/deepagent-code/src/session/tools.ts @@ -45,21 +45,9 @@ export function validatedToolInputSchema(parameters: Schema.Decoder, wi }) } -// Plan-gate WARN reminder placement. Prepending "⚠️ Plan gate: …" AHEAD of a tool's own output was -// actively harmful: the plan gate is a soft nudge — the tool ALREADY RAN and the output below the -// banner is its real (usually successful, exit 0) result. But a leading "⚠️ … gate …" line reads as a -// FAILURE/denial, so the model recorded the call as a plan-gate-blocked failure, then re-diagnosed that -// same false negative every round ("the previous 'failures' are again plan-gate artifacts — the bash -// actually returned data"). Append the note AFTER the output and state plainly that the command ran, so -// the real result leads and the nudge cannot be misread as a block. (Hard-BLOCK denials are unaffected — -// they return their own message and never reach here because the tool did not execute.) -export const appendPlanGateNote = (output: string, reason: string): string => - `${output}\n\n---\n[plan-note] This command executed normally; the output above is its real result. Nudge: ${reason}.` - -// U1 PlanController soft gate: a HookPolicy with the before_tool_use plan gate. While the runtime -// has flagged the plan as stale, mutating tools (write/edit/patch/shell) are soft-blocked until the -// model calls `plan` to update it; read/diagnosis/`plan` always pass. Lightweight modes -// (general/direct) only warn. Evaluated at the per-tool dispatch chokepoint below. +// U1 PlanController gate: a HookPolicy with the before_tool_use plan gate. The current policy lets +// tools execute and keeps stale-plan warnings in runtime logs; finalization owns plan enforcement. +// The defensive block path remains for a future safety hook that explicitly denies execution. const PlanHook = new AgentGateway.DeepAgentHooks.HookPolicy().on( "before_tool_use", AgentGateway.DeepAgentHooks.planGate(), @@ -152,10 +140,11 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { // Shared plan-gate chokepoint (U1 soft gate + U9 hard gate). BOTH the builtin loop AND the MCP loop // must run this: a mutating tool of EITHER kind that is not bound to a fresh plan step has to be gated, // otherwise the model can route all mutations through MCP tools while the plan latch is stale and the - // gate is a silent no-op. Returns a directive the caller applies: "block" → return a soft tool-result - // WITHOUT executing; "warn" → execute then prepend the reminder; "pass" → execute normally. + // gate is a silent no-op. Returns a directive the caller applies: "block" → return a soft + // tool-result WITHOUT executing; "pass" → execute normally. Soft warnings are deduplicated and + // written only to the process log, never to provider-visible or durable conversation content. // `isMutating` is supplied by the caller (builtin: classifier on the command; MCP: risk tier). - type GateDirective = { kind: "block"; output: string } | { kind: "warn"; reason: string } | { kind: "pass" } + type GateDirective = { kind: "block"; output: string } | { kind: "pass" } const evaluatePlanGate = (sessionID: string, isMutating: boolean): GateDirective => { const latch = AgentGateway.DeepAgentSessionState.planLatch(sessionID) const planStale = latch?.latch === "stale" && !AgentGateway.DeepAgentPlanController.shouldEscapeToHuman(latch) @@ -201,7 +190,20 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { AgentGateway.DeepAgentSessionState.recordMutation(sessionID) AgentGateway.DeepAgentSessionState.resetPlanGateBlocks(sessionID) } - return gateWarnReason ? { kind: "warn", reason: gateWarnReason } : { kind: "pass" } + if (!gateWarnReason) return { kind: "pass" } + const fingerprint = JSON.stringify([ + latch?.plan_id ?? null, + latch?.latch ?? null, + latch?.stale_reason ?? null, + latch?.replan_count ?? 0, + plan?.active_step_id ?? null, + AgentGateway.DeepAgentSessionState.get(sessionID)?.lastAdmissionUserMessageId ?? null, + gateWarnReason, + ]) + if (AgentGateway.DeepAgentSessionState.claimPlanGateNudge(sessionID, fingerprint)) { + log.info("plan gate warning", { sessionID, reason: gateWarnReason }) + } + return { kind: "pass" } } for (const item of yield* registry.tools({ @@ -244,22 +246,16 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { if (gate.kind === "block") { return { title: "Plan update required", output: gate.output, metadata: {} } } - const gateWarnReason = gate.kind === "warn" ? gate.reason : undefined const result = yield* item.execute(args, ctx).pipe( - // I33-2: any tool execution failure marks the plan stale (tool_failed reason) so the - // model is nudged to re-plan before continuing. The gate is warn-only now (v4.0.4 P1), - // so this is a soft nudge: plan → stale but the next mutating tool still runs with a - // reminder rather than being blocked. + // I33-2: any tool execution failure marks the plan stale (tool_failed reason). + // Finalization and the next runtime-system boundary enforce that state; tool output + // remains exclusively the tool's real result. Effect.tapError(() => Effect.sync(() => AgentGateway.DeepAgentSessionState.markPlanStale(ctx.sessionID, "tool_failed")), ), ) - const withReminder = - gateWarnReason && typeof result.output === "string" - ? { ...result, output: appendPlanGateNote(result.output, gateWarnReason) } - : result const output = { - ...withReminder, + ...result, attachments: result.attachments?.map((attachment) => ({ ...attachment, id: PartID.ascending(), @@ -343,7 +339,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { content: [{ type: "text" as const, text: mcpGate.output }], } } - const mcpGateWarnReason = mcpGate.kind === "warn" ? mcpGate.reason : undefined yield* plugin.trigger( "tool.execute.before", { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, @@ -395,10 +390,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { } } - const joinedText = mcpGateWarnReason - ? appendPlanGateNote(textParts.join("\n\n"), mcpGateWarnReason) - : textParts.join("\n\n") - const truncated = yield* truncate.output(joinedText, {}, input.agent) + const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent) const metadata = { ...result.metadata, truncated: truncated.truncated, diff --git a/packages/deepagent-code/src/tool/internal.ts b/packages/deepagent-code/src/tool/internal.ts new file mode 100644 index 00000000..93cec2d6 --- /dev/null +++ b/packages/deepagent-code/src/tool/internal.ts @@ -0,0 +1,13 @@ +import type { Tool } from "ai" + +const tools = new WeakSet() + +export function set(tool: Tool): void { + tools.add(tool) +} + +export function has(tool: Tool): boolean { + return tools.has(tool) +} + +export * as ToolInternal from "./internal" diff --git a/packages/deepagent-code/src/tool/shell.ts b/packages/deepagent-code/src/tool/shell.ts index 23b4684c..75887a09 100644 --- a/packages/deepagent-code/src/tool/shell.ts +++ b/packages/deepagent-code/src/tool/shell.ts @@ -22,6 +22,7 @@ import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { ShellPrompt, type Parameters } from "./shell/prompt" import { BashArity } from "@/permission/arity" +import { Hash } from "@deepagent-code/core/util/hash" export { Parameters } from "./shell/prompt" @@ -636,7 +637,7 @@ export const ShellTool = Tool.define( }), resultFingerprint: (result) => ({ exit: result.metadata.exit, - output: new Bun.CryptoHasher("sha256").update(result.output).digest("hex"), + output: Hash.sha256(result.output), attachments: result.attachments?.map((attachment) => ({ mime: attachment.mime, filename: attachment.filename, diff --git a/packages/deepagent-code/src/tool/task-run.ts b/packages/deepagent-code/src/tool/task-run.ts index 70c5dab5..6d381831 100644 --- a/packages/deepagent-code/src/tool/task-run.ts +++ b/packages/deepagent-code/src/tool/task-run.ts @@ -9,6 +9,7 @@ import { and, asc, eq, gt, inArray, isNull, lte, max, or, sql } from "drizzle-or import { Cause, Data, Effect } from "effect" import { Identifier } from "@/id/id" import { MessageID, SessionID } from "@/session/schema" +import { Hash } from "@deepagent-code/core/util/hash" export type State = | "admitted" @@ -88,7 +89,7 @@ const canonicalJson = (value: unknown): string => { .join(",")}}` } -export const requestHash = (value: unknown) => new Bun.CryptoHasher("sha256").update(canonicalJson(value)).digest("hex") +export const requestHash = (value: unknown) => Hash.sha256(canonicalJson(value)) const fromRow = (row: typeof TaskRunTable.$inferSelect): Run => ({ runID: row.run_id, diff --git a/packages/deepagent-code/test/deepagent/multiround.test.ts b/packages/deepagent-code/test/deepagent/multiround.test.ts index 8228615f..bfb3dfcd 100644 --- a/packages/deepagent-code/test/deepagent/multiround.test.ts +++ b/packages/deepagent-code/test/deepagent/multiround.test.ts @@ -71,6 +71,7 @@ function ops(sessionID: string, over: Partial>): MultiRoun sessionID, agentMode: "max", enabled: true, + autonomous: true, maxRounds: 3, first: "turn-1", validationCommands: ["npm test"], @@ -103,6 +104,7 @@ describe("A6 multi-round loop (Effect)", () => { maybeRunRounds( ops(sessionID, { agentMode: "high", + autonomous: false, runValidation: () => { validationRounds++ return Effect.succeed([vr("npm test", true)]) @@ -114,6 +116,39 @@ describe("A6 multi-round loop (Effect)", () => { expect(validationRounds).toBe(1) }) + test("non-autonomous modes report a failed validation without injecting a user turn", async () => { + const sessionID = setup() + let revises = 0 + let restores = 0 + let emitted: { status: string; body: string } | undefined + const out = await Effect.runPromise( + maybeRunRounds( + ops(sessionID, { + agentMode: "max", + autonomous: false, + maxRounds: null, + runValidation: () => Effect.succeed([vr("npm test", false)]), + restore: () => { + restores++ + return Effect.void + }, + reviseTurn: () => { + revises++ + return Effect.succeed("synthetic-revision") + }, + onMacroRound: (suggestion) => + Effect.sync(() => { + emitted = suggestion + }), + }), + ), + ) + expect(out).toBe("turn-1") + expect(revises).toBe(0) + expect(restores).toBe(0) + expect(emitted?.status).toBe("needs_human") + }) + test("env opt-out disables the mode-driven loop", () => { const previous = process.env.DEEPAGENT_MULTIROUND process.env.DEEPAGENT_MULTIROUND = "0" diff --git a/packages/deepagent-code/test/deepagent/plan-status-cache.test.ts b/packages/deepagent-code/test/deepagent/plan-status-cache.test.ts index 6f6e93aa..5ed26bcc 100644 --- a/packages/deepagent-code/test/deepagent/plan-status-cache.test.ts +++ b/packages/deepagent-code/test/deepagent/plan-status-cache.test.ts @@ -4,10 +4,9 @@ import { Effect } from "effect" import { LLMRequestPrep } from "../../src/session/llm/request" import { SessionReminders } from "../../src/session/reminders" -// Regression coverage for the prompt-cache fix (docs/deepagent-cache-hit-fix-plan.md): the plan-status -// snapshot must NOT be pushed onto a user history message (it changes every step and busted the cache -// from that anchor through all tool-loop history). It now rides the trailing volatile round-context -// message, after the Anthropic cache breakpoint. These tests lock that contract in. +// Plan status is trusted runtime control. It must stay out of durable user history and travel in the +// same privileged runtime system message as round context. The stable base system message remains +// byte-identical even when the plan advances. const plugin = { trigger: (_name: string, _input: unknown, output: unknown) => Effect.succeed(output), @@ -67,6 +66,8 @@ async function prepare(sessionID: string, messages: any[], metadata?: Record { - const last = prepared.messages[prepared.messages.length - 1] - if (!last || last.role !== "user") return "" - return typeof last.content === "string" ? last.content : JSON.stringify(last.content) -} +const runtimeSystem = (prepared: { messages: any[] }): string => + prepared.messages.find( + (message) => message.role === "system" && typeof message.content === "string" && message.content.startsWith(""), + )?.content ?? "" const userHistory = (prepared: { messages: any[] }): string => prepared.messages - .slice(0, -1) // exclude the trailing volatile message .filter((m) => m.role === "user") .map((m) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content))) .join("\n") +const stableMessages = (prepared: { messages: any[] }) => + prepared.messages.filter( + (message) => !(message.role === "system" && typeof message.content === "string" && message.content.startsWith("")), + ) + describe("plan-status prompt-cache fix", () => { test("renderPlanStatus returns the snapshot text in high mode with a plan", () => { AgentGateway.configure({ enabled: true, agentMode: "high" }) @@ -128,26 +132,35 @@ describe("plan-status prompt-cache fix", () => { AgentGateway.configure({ enabled: false, agentMode: "high" }) }) - test("plan-status rides the trailing volatile message, NOT the user history", async () => { + test("does not send first-round plan telemetry for a non-orchestrated task", async () => { + AgentGateway.configure({ enabled: true, agentMode: "high" }) + const sessionID = `ses_planstatus_noop_${crypto.randomUUID()}` + seedPlan(sessionID, 0, 2, 0) + const prepared = await prepare(sessionID, [{ role: "user", content: "fix one typo" }]) + expect(runtimeSystem(prepared)).toBe("") + expect(JSON.stringify(prepared.messages.filter((message) => message.role === "user"))).not.toContain("plan-status") + AgentGateway.configure({ enabled: false, agentMode: "high" }) + }) + + test("plan-status uses system authority, not user history", async () => { AgentGateway.configure({ enabled: true, agentMode: "high" }) const sessionID = `ses_planstatus_tail_${crypto.randomUUID()}` seedPlan(sessionID, 1, 3, 2) - const prepared = await prepare(sessionID, [ - { role: "user", content: "implement the parser" }, - { role: "assistant", content: "working on it" }, - ]) - // plan-status must appear ONLY in the trailing volatile user message. - expect(tail(prepared)).toContain("") - expect(tail(prepared)).toContain("Current plan (1/3 done)") - // ...and must NOT have been injected into any prior (cached-prefix) user message. + const prepared = await prepare( + sessionID, + [ + { role: "user", content: "implement the parser" }, + { role: "assistant", content: "working on it" }, + ], + continueRound, + ) + expect(runtimeSystem(prepared)).toContain("") + expect(runtimeSystem(prepared)).toContain("Current plan (1/3 done)") expect(userHistory(prepared)).not.toContain("") AgentGateway.configure({ enabled: false, agentMode: "high" }) }) - // The core invariant: across a simulated tool loop where plan progress + mutation count advance every - // step (exactly the values that used to bust the cache), the ENTIRE prefix before the trailing volatile - // message must stay byte-identical. Only the last message may differ. - test("cached prefix is byte-stable across tool-loop steps despite advancing plan progress", async () => { + test("stable base prompt and history remain unchanged while runtime plan state advances", async () => { AgentGateway.configure({ enabled: true, agentMode: "high" }) const sessionID = `ses_planstatus_prefix_${crypto.randomUUID()}` const history = [ @@ -158,51 +171,41 @@ describe("plan-status prompt-cache fix", () => { // Step A: 1/3 done, 2 mutations. seedPlan(sessionID, 1, 3, 2) - const stepA = await prepare(sessionID, history) + const stepA = await prepare(sessionID, history, continueRound) // Step B: same history prefix, but plan advanced to 2/3 done and mutation count changed — the exact // per-step churn that previously busted the cache when written onto the user anchor. seedPlan(sessionID, 2, 3, 5) - const stepB = await prepare(sessionID, history) + const stepB = await prepare(sessionID, history, continueRound) - const prefixA = stepA.messages.slice(0, -1) - const prefixB = stepB.messages.slice(0, -1) - // The whole prefix (system + history, everything before the trailing volatile message) is identical. - expect(JSON.stringify(prefixB)).toBe(JSON.stringify(prefixA)) - // ...while the trailing volatile message DID reflect the advancing plan (proving it moved, not vanished). - expect(tail(stepA)).toContain("1/3 done") - expect(tail(stepB)).toContain("2/3 done") - // Exactly one trailing volatile message is appended (not two) — appending a second would shift the - // cache breakpoint off the last stable history message. - expect(stepA.messages.length).toBe(history.length + 1 /* system */ + 1 /* volatile tail */) + expect(JSON.stringify(stableMessages(stepB))).toBe(JSON.stringify(stableMessages(stepA))) + expect(runtimeSystem(stepA)).toContain("1/3 done") + expect(runtimeSystem(stepB)).toContain("2/3 done") + expect(stepA.messages.length).toBe(history.length + 1 /* stable system */ + 1 /* runtime system */) AgentGateway.configure({ enabled: false, agentMode: "high" }) }) - test("plan-status shares the SINGLE trailing message with the round-context block", async () => { + test("plan-status shares one runtime system message with round context", async () => { AgentGateway.configure({ enabled: true, agentMode: "high" }) const sessionID = `ses_planstatus_single_tail_${crypto.randomUUID()}` seedPlan(sessionID, 1, 2, 1) - const prepared = await prepare(sessionID, [{ role: "user", content: "do it" }]) - // Both the round-context and the plan-status live in the one trailing message. - expect(tail(prepared)).toContain("deepagent-round-context") - expect(tail(prepared)).toContain("") - // The tail is the only user message after the system block + original history. - const trailingUserMessages = prepared.messages.filter( - (m, i) => m.role === "user" && i === prepared.messages.length - 1, + const prepared = await prepare(sessionID, [{ role: "user", content: "do it" }], continueRound) + expect(runtimeSystem(prepared)).toContain("deepagent-round-context") + expect(runtimeSystem(prepared)).toContain("") + const runtimeMessages = prepared.messages.filter( + (message) => message.role === "system" && typeof message.content === "string" && message.content.startsWith(""), ) - expect(trailingUserMessages).toHaveLength(1) + expect(runtimeMessages).toHaveLength(1) + expect(prepared.messages.filter((message) => message.role === "user")).toHaveLength(1) AgentGateway.configure({ enabled: false, agentMode: "high" }) }) }) // V4.1 §S3.1 — the goal plan HOT-EDIT (§S2) cache contract. A user revising a running goal's plan -// (loop.applyPlanEdit writes the durable doc; the next tick's seedChildPlan mirrors it into the worker's -// plan-state that renderPlanStatus reads) changes ONLY the trailing volatile plan-status message. The -// cached prefix (system + real history) must stay byte-identical across the edit — the plan revision is -// exactly the kind of per-turn churn that would bust the cache if it leaked onto a history anchor. -// steer's tail-anchoring is locked separately in session/steer.test.ts ("steer only adds a tail history -// message: system prefix byte-identical"); here we lock the PLAN-EDIT side of the same red-line. -describe("V4.1 §S3.1 — goal plan hot-edit stays tail-anchored (cache red-line)", () => { +// (loop.applyPlanEdit writes the durable doc; the next tick's seedChildPlan mirrors it into the +// worker's plan-state that renderPlanStatus reads) changes only the runtime system message. It must +// never leak onto a durable user-history anchor. +describe("V4.1 §S3.1 — goal plan hot-edit stays runtime-system scoped", () => { // Apply a user plan revision the way the goal bridge surfaces it to the prompt on the next tick: the // reconciled PlanDoc is set into the session's plan-state (getPlan/setPlan), which is renderPlanStatus's // source of truth. Mirrors buildPlanFromInput → setPlan, the seedChildPlan path in goal-loop-wiring. @@ -212,7 +215,7 @@ describe("V4.1 §S3.1 — goal plan hot-edit stays tail-anchored (cache red-line AgentGateway.DeepAgentSessionState.setPlan(sessionID, plan) } - test("a plan edit moves the tail plan-status but leaves the cached prefix byte-identical", async () => { + test("a plan edit changes runtime status but leaves base prompt and history byte-identical", async () => { AgentGateway.configure({ enabled: true, agentMode: "high" }) const sessionID = `ses_planedit_prefix_${crypto.randomUUID()}` const history = [ @@ -223,11 +226,11 @@ describe("V4.1 §S3.1 — goal plan hot-edit stays tail-anchored (cache red-line // Before the edit: a 3-step plan, first done. seedPlan(sessionID, 1, 3, 2) - const before = await prepare(sessionID, history) - expect(tail(before)).toContain("Current plan (1/3 done)") + const before = await prepare(sessionID, history, continueRound) + expect(runtimeSystem(before)).toContain("Current plan (1/3 done)") // User hot-edits: re-open step 1 (done→pending), rename it, and drop a step — the exact structural - // churn a running-goal edit produces. Reflected in the tail via the plan-state render path. + // churn a running-goal edit produces. Reflected through the plan-state render path. applyEdit(sessionID, { goal: "ship the feature", steps: [ @@ -235,19 +238,12 @@ describe("V4.1 §S3.1 — goal plan hot-edit stays tail-anchored (cache red-line { step_id: "step_2", title: "Step 2", status: "pending" }, ], }) - const after = await prepare(sessionID, history) + const after = await prepare(sessionID, history, continueRound) - // The tail DID reflect the revision (proving the edit surfaces, not vanishes). - expect(tail(after)).toContain("Current plan (0/2 done)") - expect(tail(after)).toContain("reworked step") - // ...while the ENTIRE cached prefix (system + real history, everything before the volatile tail) is - // byte-identical across the edit — the cache breakpoint is preserved. - const prefixBefore = before.messages.slice(0, -1) - const prefixAfter = after.messages.slice(0, -1) - expect(JSON.stringify(prefixAfter)).toBe(JSON.stringify(prefixBefore)) - // Still exactly one trailing volatile message (no second tail introduced that would shift slice(-2)). + expect(runtimeSystem(after)).toContain("Current plan (0/2 done)") + expect(runtimeSystem(after)).toContain("reworked step") + expect(JSON.stringify(stableMessages(after))).toBe(JSON.stringify(stableMessages(before))) expect(after.messages.length).toBe(before.messages.length) - // The revision NEVER leaked into a prior user-history anchor. expect(userHistory(after)).not.toContain("reworked step") AgentGateway.configure({ enabled: false, agentMode: "high" }) }) @@ -259,9 +255,7 @@ describe("V4.1 §S3.1 — goal plan hot-edit stays tail-anchored (cache red-line // Turn 1 baselines: cache write, no reads (first turn is always a write). expect(() => LLMRequestPrep.recordCacheHitOutcome(sessionID, { input: 500, cache: { read: 0, write: 1180 } })).not.toThrow() - // A hot-edit keeps the cached prefix unchanged (§S3.1 above proves it), so the next turn serves a - // strong cache read. The monitor must NOT read this as a break (that signature is a COLLAPSED ratio - // on a NON-shrinking prompt); a healthy read is the happy path and never warns/throws. + // Cache telemetry remains diagnostic-only across the plan edit. applyEdit(sessionID, { goal: "ship the feature", steps: [{ step_id: "step_1", title: "reworked", status: "pending" }] }) expect(() => LLMRequestPrep.recordCacheHitOutcome(sessionID, { input: 12, cache: { read: 1180, write: 0 } })).not.toThrow() AgentGateway.configure({ enabled: false, agentMode: "high" }) diff --git a/packages/deepagent-code/test/deepagent/request-prep.test.ts b/packages/deepagent-code/test/deepagent/request-prep.test.ts index 677f3741..81841f86 100644 --- a/packages/deepagent-code/test/deepagent/request-prep.test.ts +++ b/packages/deepagent-code/test/deepagent/request-prep.test.ts @@ -3,6 +3,7 @@ import { AgentGateway } from "@deepagent-code/core/agent-gateway" import { Effect } from "effect" import { LLMRequestPrep } from "../../src/session/llm/request" import { ToolProvenance } from "../../src/tool/provenance" +import { ToolInternal } from "../../src/tool/internal" const plugin = { trigger: (_name: string, _input: unknown, output: unknown) => Effect.succeed(output), @@ -47,20 +48,23 @@ const model = (providerID: string, modelID: string) => headers: {}, }) as any -// Prompt-cache split (docs/deepagent-cache-hit-fix-plan.md): the round/stage/budget context is no -// longer baked into the cached system prefix — it is appended as a trailing user message. Read it -// back from the LAST message so the round-advance assertions test the right place. -const tailContext = (prepared: { messages: any[] }): string => { - const last = prepared.messages[prepared.messages.length - 1] - if (!last || last.role !== "user") return "" - return typeof last.content === "string" ? last.content : JSON.stringify(last.content) +const roundContext = (prepared: { messages: any[] }): string => { + const message = prepared.messages.find( + (item) => item.role === "system" && typeof item.content === "string" && item.content.startsWith(""), + ) + return message?.content ?? "" } async function prepare( providerID: string, modelID: string, sessionID = `ses_deepagent_request_prep_${providerID}_${modelID}`, - options: { messages?: any[]; metadata?: Record } = {}, + options: { + messages?: any[] + metadata?: Record + runtimeTail?: string + federatedProjection?: boolean + } = {}, ) { return Effect.runPromise( LLMRequestPrep.prepare({ @@ -82,11 +86,62 @@ async function prepare( plugin, flags: { outputTokenMax: 32_000, client: "test" } as any, isWorkflow: false, + runtimeTail: options.runtimeTail, + federatedProjection: options.federatedProjection, }), ) } describe("DeepAgent request prep", () => { + test("keeps marked internal tools while applying agent permission denies", async () => { + AgentGateway.configure({ enabled: false, agentMode: "general" }) + const structuredOutput = {} as any + const external = {} as any + ToolInternal.set(structuredOutput) + const prepared = await Effect.runPromise( + LLMRequestPrep.prepare({ + user: user("deepseek", "deepseek-chat", "ses_internal_tool_permission"), + sessionID: "ses_internal_tool_permission", + model: model("deepseek", "deepseek-chat"), + agent: { + name: "researcher", + mode: "subagent", + prompt: "researcher", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "deny" }], + } as any, + system: [], + messages: [{ role: "user", content: "finalize" }], + tools: { StructuredOutput: structuredOutput, external }, + provider: { id: "deepseek", options: {} } as any, + auth: undefined, + plugin, + flags: { outputTokenMax: 32_000, client: "test" } as any, + isWorkflow: false, + }), + ) + + expect(Object.keys(prepared.tools)).toEqual(["StructuredOutput"]) + }) + + test("suppresses no-op first-round state while keeping federated projection in a data tail", async () => { + AgentGateway.configure({ enabled: true, agentMode: "high" }) + const prepared = await prepare("deepagent", "deepseek-deepseek-v4-flash", crypto.randomUUID(), { + runtimeTail: "project-context-json-v1 bytes=2\n{}", + federatedProjection: true, + }) + const tails = prepared.messages.filter( + (message) => message.role === "user" && typeof message.content === "string" && message.content.includes("project-context-json-v1"), + ) + expect(tails).toHaveLength(1) + const tail = tails[0].content as string + expect(tail).toContain("project-context-json-v1 bytes=2") + expect(tail).toStartWith("\n") + expect(tail).toEndWith("\n") + expect(roundContext(prepared)).toBe("") + expect(prepared.system.join("\n")).not.toContain("project-context-json-v1") + }) + // V3.1 global runtime: in high/max the DeepAgent system prompt is injected for EVERY provider // (DeepAgent is a global agent system, not a provider). The distinguishing axis is strength // (general vs high/max), not providerID. See deepagent-production-contract.md "Runtime Boundary". @@ -96,10 +151,9 @@ describe("DeepAgent request prep", () => { expect(deepagent.system[0]).toContain("DeepAgent Code") expect(deepagent.system[0]).not.toContain("DeepCode") expect(deepagent.system[0]).toContain("High") - // Prompt-cache split: the activation stage is round-derived and now lives in the volatile tail - // message, NOT the cached system prefix. + // A simple first-round task has no actionable runtime update. expect(deepagent.system[0]).not.toContain("first_fast_design") - expect(tailContext(deepagent)).toContain("first_fast_design") + expect(roundContext(deepagent)).toBe("") expect(deepagent.system[0]).not.toContain("generic agent prompt") expect(deepagent.system[0]).not.toContain("You are deepagent-code") expect(deepagent.messages[0]).toMatchObject({ @@ -117,7 +171,7 @@ describe("DeepAgent request prep", () => { role: "system", content: expect.stringContaining("DeepAgent Code"), }) - expect(ordinary.messages[1]).toMatchObject({ role: "user", content: "hello" }) + expect(ordinary.messages.find((message) => message.role === "user" && message.content === "hello")).toBeDefined() }) test("general mode keeps the DeepAgent provider on the default agent path", async () => { @@ -163,7 +217,7 @@ describe("DeepAgent request prep", () => { // a per-turn fan-out VERDICT into the system prompt — that was request-text-derived and busted the // cache. The system block now carries only the STABLE, mode-derived generic guidance, so it is // byte-identical regardless of the request. (The DeepAgent path surfaces the concrete verdict via the - // volatile tail context instead.) + // separate runtime system context instead.) test("§5b system prompt carries only stable orchestration guidance, no per-turn verdict (non-DeepAgent, high)", async () => { AgentGateway.configure({ enabled: false, agentMode: "high" }) const complex = await prepare("deepseek", "deepseek-v4-flash", "ses_orch_decision_complex", { @@ -238,7 +292,7 @@ describe("DeepAgent request prep", () => { ], }, ) - expect(tailContext(prepared)).toContain("第 1 轮") + expect(roundContext(prepared)).toBe("") // The round number must NOT be in the cached system prefix (prompt-cache invariant). expect(prepared.system[0]).not.toContain("第 1 轮") }) @@ -259,7 +313,7 @@ describe("DeepAgent request prep", () => { ], }, ) - expect(tailContext(prepared)).toContain("第 2 轮") + expect(roundContext(prepared)).toContain("第 2 轮") }) // T3 (S1-v3.4): the advance-trigger set {continue, revise, narrow} advances the round; the terminal @@ -280,7 +334,7 @@ describe("DeepAgent request prep", () => { ], }, ) - expect(tailContext(prepared)).toContain("第 2 轮") + expect(roundContext(prepared)).toContain("第 2 轮") }) } @@ -302,9 +356,9 @@ describe("DeepAgent request prep", () => { // The cached system prefix must not change even though the round advanced 1 → 2. expect(round2.system[0]).toBe(round1.system[0]) - // ...and the round actually advanced, proving the round number lives only in the volatile tail. - expect(tailContext(round1)).toContain("第 1 轮") - expect(tailContext(round2)).toContain("第 2 轮") + // The no-op first round is silent; an explicit continuation receives actionable runtime state. + expect(roundContext(round1)).toBe("") + expect(roundContext(round2)).toContain("第 2 轮") }) for (const action of ["stop", "escalate"]) { @@ -323,7 +377,7 @@ describe("DeepAgent request prep", () => { ], }, ) - expect(tailContext(prepared)).toContain("第 1 轮") + expect(roundContext(prepared)).toBe("") }) } diff --git a/packages/deepagent-code/test/provider/discovery-cache.test.ts b/packages/deepagent-code/test/provider/discovery-cache.test.ts index 8c170cae..02db317e 100644 --- a/packages/deepagent-code/test/provider/discovery-cache.test.ts +++ b/packages/deepagent-code/test/provider/discovery-cache.test.ts @@ -7,7 +7,7 @@ import { FSUtil } from "@deepagent-code/core/fs-util" import { EffectFlock } from "@deepagent-code/core/util/effect-flock" import { Global } from "@deepagent-code/core/global" import { discoverModelsCached, type DiscoverModelsCachedInput } from "@/provider/discovery-cache" -import type { DiscoveredModel } from "@/provider/model-discovery" +import { ProviderDiscoveryError, type DiscoveredModel } from "@/provider/model-discovery" // Point the data root at a throwaway dir so cache files (Global.Path.cache) never touch the real // home. Global reads DEEPAGENT_CODE_HOME lazily on each Path.cache access. @@ -111,6 +111,30 @@ describe("discoverModelsCached", () => { expect(calls).toBe(2) }) + test("never masks an authentication failure with stale models and throttles retries", async () => { + let calls = 0 + const fetch = async () => { + calls++ + if (calls === 1) return [model("revoked-later")] + throw new ProviderDiscoveryError("revoked", 401) + } + const input = { ...baseInput("revoked"), ttl: Duration.zero } + + await run((fs, flock) => discoverModelsCached(fs, flock, input, fetch)) + await expect(run((fs, flock) => discoverModelsCached(fs, flock, input, fetch))).rejects.toBeInstanceOf( + ProviderDiscoveryError, + ) + await expect(run((fs, flock) => discoverModelsCached(fs, flock, input, fetch))).rejects.toBeInstanceOf( + ProviderDiscoveryError, + ) + expect(calls).toBe(2) + + await expect(run((fs, flock) => discoverModelsCached(fs, flock, input, fetch, true))).rejects.toBeInstanceOf( + ProviderDiscoveryError, + ) + expect(calls).toBe(3) + }) + test("returns [] when there is no cache and the fetch fails", async () => { const fetch = async () => { throw new Error("HTTP 404") diff --git a/packages/deepagent-code/test/provider/model-discovery.test.ts b/packages/deepagent-code/test/provider/model-discovery.test.ts index 423ff277..a2af6c20 100644 --- a/packages/deepagent-code/test/provider/model-discovery.test.ts +++ b/packages/deepagent-code/test/provider/model-discovery.test.ts @@ -1,5 +1,11 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test" -import { discoverProviderModels, discoverWithProtocol, isChatModel, normalizeBaseURL } from "@/provider/model-discovery" +import { + ProviderDiscoveryError, + discoverProviderModels, + discoverWithProtocol, + isChatModel, + normalizeBaseURL, +} from "@/provider/model-discovery" describe("normalizeBaseURL", () => { test("strips query, hash and trailing slashes", () => { @@ -30,16 +36,22 @@ describe("discoverWithProtocol", () => { expect(result.models).toHaveLength(1) }) - test("probes openai-compatible first when kind omitted", async () => { + test("uses endpoint metadata when both authentication styles can list models", async () => { const tried: string[] = [] const result = await discoverWithProtocol(input, async (kind) => { tried.push(kind) - return [{ id: "m", name: "M" }] + return [{ id: "m", name: "M", protocols: ["openai-compatible"] }] }) - expect(tried).toEqual(["openai-compatible"]) + expect(tried).toEqual(["openai-compatible", "anthropic"]) expect(result.kind).toBe("openai-compatible") }) + test("rejects ambiguous protocol detection instead of silently choosing OpenAI", async () => { + await expect(discoverWithProtocol(input, async () => [{ id: "m", name: "M" }])).rejects.toThrow( + "protocol is ambiguous", + ) + }) + test("falls back to anthropic when openai-compatible yields nothing", async () => { const tried: string[] = [] const result = await discoverWithProtocol(input, async (kind) => { @@ -95,6 +107,26 @@ describe("discoverProviderModels (real fetch)", () => { expect(models).toEqual([{ id: "model-a", name: "Model A" }]) }) + test("preserves endpoint protocol metadata", async () => { + server?.stop(true) + server = Bun.serve({ + port: 0, + fetch: () => Response.json({ data: [{ id: "claude-x", supported_endpoint_types: ["anthropic-messages"] }] }), + }) + baseURL = `http://localhost:${server.port}/v1` + const models = await discoverProviderModels({ baseURL, apiKey: "k", providerID: "relay", kind: "anthropic" }) + expect(models).toEqual([{ id: "claude-x", name: "claude-x", protocols: ["anthropic"] }]) + }) + + test("returns a typed HTTP error for authentication failures", async () => { + server?.stop(true) + server = Bun.serve({ port: 0, fetch: () => new Response("unauthorized", { status: 401 }) }) + baseURL = `http://localhost:${server.port}/v1` + await expect(discoverProviderModels({ baseURL, apiKey: "bad", providerID: "relay" })).rejects.toBeInstanceOf( + ProviderDiscoveryError, + ) + }) + test("rejects (does not hang) when the host is unreachable", async () => { // Port 1 is a reserved port nothing listens on → connection refused resolves fast, proving the // call surfaces an error instead of blocking the caller forever. diff --git a/packages/deepagent-code/test/provider/provider.test.ts b/packages/deepagent-code/test/provider/provider.test.ts index 4326a738..a09f96db 100644 --- a/packages/deepagent-code/test/provider/provider.test.ts +++ b/packages/deepagent-code/test/provider/provider.test.ts @@ -124,6 +124,7 @@ beforeAll(() => { fetch(req) { const url = new URL(req.url) if (url.pathname.endsWith("/models")) { + if (url.pathname.startsWith("/unauthorized")) return new Response("unauthorized", { status: 401 }) // `/empty/models` returns a 200 with no models (provisioning / not-implemented shape); every // other path returns two chat models plus one embedding model that runtime filtering must drop. if (url.pathname.startsWith("/empty")) return Response.json({ data: [] }) @@ -315,6 +316,30 @@ it.instance( }, ) +it.instance( + "discovery authentication failure suppresses configured model snapshots", + Effect.gen(function* () { + const providerID = ProviderV2.ID.make("runtime-revoked") + expect((yield* list)[providerID]).toBeUndefined() + expect( + (yield* Provider.use.errors()).find((error) => error.source === `provider.${providerID}`)?.message, + ).toContain("HTTP 401") + }), + { + config: () => ({ + provider: { + "runtime-revoked": { + name: "Runtime Revoked", + npm: "@ai-sdk/openai-compatible", + discovery: true, + options: { apiKey: "revoked", baseURL: `${discoveryServerURL}/../unauthorized` }, + models: { "stale-snapshot": { name: "Stale Snapshot" } }, + }, + }, + }), + }, +) + it.instance( "third-party model with a bare well-known id inherits specs from the models.dev catalog", Effect.gen(function* () { @@ -2286,7 +2311,11 @@ it.effect("provider groups: models in a group use the group npm (protocol overri npm: "@ai-sdk/anthropic", options: { apiKey: "claude-key" }, models: { - "claude-3-5-sonnet": { name: "Claude 3.5 Sonnet", tool_call: true, limit: { context: 200000, output: 8096 } }, + "claude-3-5-sonnet": { + name: "Claude 3.5 Sonnet", + tool_call: true, + limit: { context: 200000, output: 8096 }, + }, }, }, }, @@ -2301,6 +2330,63 @@ it.effect("provider groups: models in a group use the group npm (protocol overri expect(gpt4o?.api.npm).toBe("@ai-sdk/openai-compatible") const claude = mygateway.models[ModelV2.ID.make("claude-3-5-sonnet")] expect(claude?.api.npm).toBe("@ai-sdk/anthropic") + expect(claude?.options.apiKey).toBeUndefined() + const language = yield* Provider.use.getLanguage(claude).pipe(provideInstanceEffect(dir)) + const config = ( + language as unknown as { + config: { baseURL: string; headers: () => Promise> } + } + ).config + expect(config.baseURL).toBe("https://gateway.example.com/v1") + expect((yield* Effect.promise(() => Promise.resolve(config.headers())))["x-api-key"]).toBe("claude-key") + }).pipe(provideMultiInstance), +) + +it.effect("provider groups: discovery isolates one group's authentication failure", () => + Effect.gen(function* () { + const server = Bun.serve({ + port: 0, + fetch: (request) => { + if (request.headers.get("authorization") !== "Bearer group-key") return new Response("bad key", { status: 401 }) + return Response.json({ data: [{ id: "group-model", name: "Group Model" }] }) + }, + }) + yield* Effect.addFinalizer(() => Effect.sync(() => server.stop(true))) + const dir = yield* tmpdirScoped({ + config: { + provider: { + groupedgateway: { + name: "Grouped Gateway", + options: { baseURL: `http://localhost:${server.port}/v1`, apiKey: "top-key" }, + groups: { + openai: { + npm: "@ai-sdk/openai-compatible", + options: { apiKey: "group-key" }, + discovery: true, + }, + anthropic: { + npm: "@ai-sdk/anthropic", + options: { apiKey: "revoked-group-key" }, + discovery: true, + }, + }, + }, + }, + }, + }) + const providers = yield* Provider.use.list().pipe(provideInstanceEffect(dir)).pipe(provideMultiInstance) + const model = providers[ProviderV2.ID.make("groupedgateway")].models[ModelV2.ID.make("group-model")] + expect(model?.api.npm).toBe("@ai-sdk/openai-compatible") + expect((model?.options as Record).apiKey).toBeUndefined() + const language = yield* Provider.use.getLanguage(model).pipe(provideInstanceEffect(dir)) + const headers = (language as unknown as { config: { headers: () => Promise> } }).config + .headers + expect((yield* Effect.promise(() => Promise.resolve(headers()))).authorization).toBe("Bearer group-key") + expect( + (yield* Provider.use.errors().pipe(provideInstanceEffect(dir))).find( + (error) => error.source === "provider.groupedgateway.groups.anthropic", + )?.message, + ).toContain("HTTP 401") }).pipe(provideMultiInstance), ) diff --git a/packages/deepagent-code/test/provider/transform.test.ts b/packages/deepagent-code/test/provider/transform.test.ts index d27457a3..55a3fd54 100644 --- a/packages/deepagent-code/test/provider/transform.test.ts +++ b/packages/deepagent-code/test/provider/transform.test.ts @@ -2568,6 +2568,22 @@ describe("ProviderTransform.variants", () => { expect(result.xhigh).toEqual({ reasoningEffort: "xhigh" }) }) + test("openai-compatible claude-opus-5 forwards max adaptive effort", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "gateway/claude-opus-5", + providerID: "gateway", + api: { + id: "claude-opus-5", + url: "https://api.gateway.ai/v1", + npm: "@ai-sdk/openai-compatible", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.max).toEqual({ reasoningEffort: "max" }) + }) + test("openai-compatible non-gpt5 model still gets only low/medium/high", () => { const model = createMockModel({ id: "third-party/some-model", @@ -3470,6 +3486,27 @@ describe("ProviderTransform.variants", () => { } } + for (const apiId of ["claude-opus-5", "claude-opus-5-20260720"]) { + test(`opus 5 ${apiId} uses max adaptive effort`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id: `anthropic/${apiId}`, + providerID: "anthropic", + api: { + id: apiId, + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.max).toEqual({ + thinking: { type: "adaptive", display: "summarized" }, + effort: "max", + }) + }) + } + test("github copilot opus 4.7 returns only medium reasoning effort", () => { const model = createMockModel({ id: "claude-opus-4.7", diff --git a/packages/deepagent-code/test/session/plan-gate-note.test.ts b/packages/deepagent-code/test/session/plan-gate-note.test.ts deleted file mode 100644 index 38164209..00000000 --- a/packages/deepagent-code/test/session/plan-gate-note.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { SessionTools } from "../../src/session/tools" - -// Regression guard for the plan-gate WARN reminder placement. The bug: "⚠️ Plan gate: …" was -// PREPENDED to a successful tool's own output, so the model read the leading gate banner as a -// FAILURE/denial and recorded the (exit-0) call as plan-gate-blocked. That false negative was then -// re-surfaced as "previous validation failed" every round, so the model re-diagnosed the same -// non-failure verbatim on every subsequent turn ("the previous 'failures' are again plan-gate -// artifacts — the bash actually returned data"). The fix appends the note AFTER the real output and -// states the command ran, so the result leads and the nudge can't be misread as a block. -describe("appendPlanGateNote (plan-gate false-negative regression)", () => { - const reason = "the plan is stale (reality changed); review it and update the `plan` tool to resync" - const realOutput = "compat.cuh\nexit code: 0\nBuild OK" - - test("the real tool output comes FIRST (not behind a gate banner)", () => { - const noted = SessionTools.appendPlanGateNote(realOutput, reason) - expect(noted.startsWith(realOutput)).toBe(true) - // The note must not lead with a warning/gate banner that reads as a failure. - expect(noted.startsWith("⚠️")).toBe(false) - expect(noted.startsWith("⚠️ Plan gate")).toBe(false) - }) - - test("states plainly that the command executed (so it is not read as a block)", () => { - const noted = SessionTools.appendPlanGateNote(realOutput, reason) - expect(noted).toContain("executed normally") - expect(noted).toContain("real result") - // The nudge reason is preserved so the model can still re-sync the plan. - expect(noted).toContain(reason) - }) - - test("preserves the output verbatim and appends the note at the tail", () => { - const noted = SessionTools.appendPlanGateNote(realOutput, reason) - const idxOutput = noted.indexOf(realOutput) - const idxNote = noted.indexOf("[plan-note]") - expect(idxOutput).toBe(0) - expect(idxNote).toBeGreaterThan(idxOutput + realOutput.length) - }) -}) diff --git a/packages/deepagent-code/test/session/retry.test.ts b/packages/deepagent-code/test/session/retry.test.ts index da726124..43113ab1 100644 --- a/packages/deepagent-code/test/session/retry.test.ts +++ b/packages/deepagent-code/test/session/retry.test.ts @@ -181,6 +181,26 @@ describe("session.retry.retryable", () => { }) }) + test("retries terminated Undici streams", () => { + const cause = Object.assign(new Error("other side closed"), { code: "UND_ERR_SOCKET" }) + const request = MessageV2.fromError(new TypeError("terminated", { cause }), { providerID }) + + expect(SessionV1.APIError.isInstance(request)).toBe(true) + if (!SessionV1.APIError.isInstance(request)) throw new Error("expected APIError") + expect(request.data.isRetryable).toBe(true) + expect(request.data.metadata).toEqual({ code: "UND_ERR_SOCKET", message: "terminated" }) + expect(SessionRetry.retryable(request, retryProvider)).toEqual({ + message: "Provider connection was interrupted", + }) + }) + + test("does not retry a terminated stream after user interruption", () => { + const request = MessageV2.fromError(new TypeError("terminated"), { providerID, aborted: true }) + + expect(SessionV1.AbortedError.isInstance(request)).toBe(true) + expect(SessionRetry.retryable(request, retryProvider)).toBeUndefined() + }) + test("does not retry context overflow errors", () => { const error = new SessionV1.ContextOverflowError({ message: "Input exceeds context window of this model", diff --git a/packages/deepagent-code/test/session/structured-output.test.ts b/packages/deepagent-code/test/session/structured-output.test.ts index 563ab530..3ad0de4e 100644 --- a/packages/deepagent-code/test/session/structured-output.test.ts +++ b/packages/deepagent-code/test/session/structured-output.test.ts @@ -4,6 +4,7 @@ import { Exit, Schema } from "effect" import { MessageV2 } from "../../src/session/message-v2" import { SessionPrompt } from "../../src/session/prompt" import { SessionID, MessageID } from "../../src/session/schema" +import { ToolInternal } from "../../src/tool/internal" const decodeFormat = Schema.decodeUnknownExit(SessionV1.Format) const decodeUser = Schema.decodeUnknownExit(SessionV1.User) @@ -254,6 +255,7 @@ describe("structured-output.createStructuredOutputTool", () => { }) expect(tool.description).toContain("structured format") + expect(ToolInternal.has(tool)).toBe(true) }) test("creates tool with schema as inputSchema", () => { diff --git a/packages/deepagent-code/test/tool/task-run.test.ts b/packages/deepagent-code/test/tool/task-run.test.ts index 0cf81108..aab62779 100644 --- a/packages/deepagent-code/test/tool/task-run.test.ts +++ b/packages/deepagent-code/test/tool/task-run.test.ts @@ -80,6 +80,7 @@ describe("TaskRun durable store", () => { expect(requestHash({ b: [2, { y: true, x: null }], a: 1 })).toBe( requestHash({ a: 1, b: [2, { x: null, y: true }] }), ) + expect(requestHash({ prompt: "a" })).toBe("732acbb3e402e912437dbe53442ca54368b45e73c284acbf4c0530fac53cca74") expect(requestHash({ prompt: "a" })).not.toBe(requestHash({ prompt: "b" })) }), ) diff --git a/packages/desktop/package.json b/packages/desktop/package.json index ed67945e..d3b26c76 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -18,7 +18,9 @@ "preview": "electron-vite preview", "test": "bun test ./src", "test:ci": "mkdir -p .artifacts/unit && bun test ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", - "test:subagents-smoke": "node --experimental-strip-types ./scripts/subagents-smoke.ts", + "test:subagents-cold-start": "node --experimental-strip-types ./scripts/subagents-smoke.ts", + "test:subagents-smoke": "node --experimental-strip-types ./scripts/subagents-live.ts", + "test:prevent-sleep-smoke": "node --experimental-strip-types ./scripts/prevent-sleep-smoke.ts", "test:subagents-sourcemap": "bun ./scripts/verify-subagents-sourcemap.ts", "package": "bun ./scripts/package.ts", "package:mac": "bun ./scripts/package.ts --mac", diff --git a/packages/desktop/scripts/prevent-sleep-smoke.ts b/packages/desktop/scripts/prevent-sleep-smoke.ts new file mode 100644 index 00000000..34d42ccd --- /dev/null +++ b/packages/desktop/scripts/prevent-sleep-smoke.ts @@ -0,0 +1,106 @@ +#!/usr/bin/env bun +// Dynamic smoke test for the "Prevent system sleep" settings toggle (macOS only). +// +// Drives the real packaged main bundle through Playwright and asserts against the OS: +// 1. default launch registers a NoIdleSleepAssertion (historical always-on default) +// 2. toggling the switch off in Settings removes the assertion while the app keeps running +// 3. the disabled state persists across a relaunch (no assertion after restart) +// 4. toggling back on re-registers the assertion +// +// Requires a prior `bun run build` (uses out/main/index.js), same as subagents-smoke. +import { strict as assert } from "node:assert" +import { execFileSync } from "node:child_process" +import { mkdtemp, realpath } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" +import { _electron as electron, type ElectronApplication, type Page } from "@playwright/test" + +if (process.platform !== "darwin") { + console.log("prevent-sleep smoke is macOS-only (pmset assertions), skipping") + process.exit(0) +} + +const root = await realpath(await mkdtemp(join(tmpdir(), "deepagent-code-prevent-sleep-smoke-"))) +const main = resolve("out/main/index.js") + +const env = Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), +) +env.DEEPAGENT_CODE_TEST_ONBOARDING = "1" +env.DEEPAGENT_CODE_TEST_ROOT = root +env.DEEPAGENT_CODE_DB = join(root, "deepagent.sqlite") +env.DEEPAGENT_CODE_DISABLE_CHANNEL_DB = "1" + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +function idleSleepAssertionPids(): number[] { + const output = execFileSync("pmset", ["-g", "assertions"], { encoding: "utf8" }) + return [...output.matchAll(/pid (\d+)\([^)]*\): \[[^\]]*\] [\d:]+ NoIdleSleepAssertion/g)].map((match) => + Number(match[1]), + ) +} + +async function waitForAssertion(pid: number, expected: boolean, timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (idleSleepAssertionPids().includes(pid) === expected) return + await sleep(500) + } + throw new Error(`timed out waiting for NoIdleSleepAssertion of pid ${pid} to become ${expected}`) +} + +async function launch() { + const app = await electron.launch({ args: [main], env, timeout: 30_000 }) + const page = await app.firstWindow({ timeout: 30_000 }) + await page.waitForFunction(() => Boolean((window as { api?: unknown }).api)) + await page.evaluate(() => + (window as unknown as { api: { awaitInitialization(): Promise } }).api.awaitInitialization(), + ) + const pid = app.process().pid + assert.ok(pid, "main process pid") + return { app, page, pid } +} + +const switchSelector = '[data-action="settings-prevent-sleep"] [data-component="switch"]' +const switchInputSelector = `${switchSelector} input[data-slot="switch-input"]` + +async function setPreventSleepViaSettings(page: Page, enabled: boolean) { + await page.keyboard.press("Meta+,") + const control = page.locator(`${switchSelector} [data-slot="switch-control"]`) + await control.waitFor({ state: "visible", timeout: 15_000 }) + // Wait until the switch reflects the opposite state before clicking, so the + // click deterministically lands on the desired state even if the resource is slow. + const before = enabled ? "false" : "true" + await page.waitForFunction( + ({ selector, before }) => document.querySelector(selector)?.getAttribute("aria-checked") === before, + { selector: switchInputSelector, before }, + { timeout: 15_000 }, + ) + await control.click() + await page.keyboard.press("Escape") +} + +// 1. Fresh launch: prevention defaults on. +const first = await launch() +await waitForAssertion(first.pid, true) +console.log("ok 1 - default launch registers NoIdleSleepAssertion") + +// 2. Turning the Settings toggle off releases the assertion without quitting. +await setPreventSleepViaSettings(first.page, false) +await waitForAssertion(first.pid, false) +console.log("ok 2 - disabling the toggle releases the assertion while running") +await first.app.close() + +// 3. Relaunch: the persisted disabled state keeps the assertion off. +const second = await launch() +await sleep(3_000) +assert.ok(!idleSleepAssertionPids().includes(second.pid), "assertion must stay off after relaunch with setting disabled") +console.log("ok 3 - disabled state persists across relaunch") + +// 4. Re-enabling registers the assertion again. +await setPreventSleepViaSettings(second.page, true) +await waitForAssertion(second.pid, true) +console.log("ok 4 - re-enabling the toggle registers the assertion again") +await second.app.close() + +console.log("prevent-sleep smoke passed") diff --git a/packages/desktop/scripts/subagents-live.ts b/packages/desktop/scripts/subagents-live.ts new file mode 100644 index 00000000..f70940b3 --- /dev/null +++ b/packages/desktop/scripts/subagents-live.ts @@ -0,0 +1,369 @@ +#!/usr/bin/env node +import { strict as assert } from "node:assert" +import { randomUUID } from "node:crypto" +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises" +import { homedir, tmpdir } from "node:os" +import { join, resolve } from "node:path" +import { _electron as electron, type ElectronApplication, type Page } from "@playwright/test" + +const MODEL = { providerID: "deepseek", modelID: "deepseek-chat" } as const +const main = resolve("out/main/index.js") +const authFile = process.env.DEEPAGENT_CODE_LIVE_AUTH_FILE ?? join(homedir(), ".deepagent", "code", "auth.json") +const savedAuth = JSON.parse(await readFile(authFile, "utf8")) as Record +const deepseekAuth = savedAuth.deepseek +assert.equal(isRecord(deepseekAuth) && deepseekAuth.type === "api" && typeof deepseekAuth.key === "string", true) + +type Server = { url: string; username: string; password: string } +type Session = { + id: string + parentID?: string + agent?: string + model?: { id: string; providerID: string } + metadata?: Record +} +type Status = { type: "idle" | "busy" | "retry" } +type Message = { + info: { + id: string + role: "user" | "assistant" + providerID?: string + modelID?: string + finish?: string + error?: unknown + time: { completed?: number } + } + parts: Array< + | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } + | { + type: "tool" + tool: string + state: + | { status: "pending" | "running" } + | { status: "completed"; output: string; metadata: Record } + | { status: "error"; error: string } + } + | { type: string } + > +} +type ToolPart = Extract +type Runtime = { + app: ElectronApplication + root: string + workspace: string + server: Server +} +type PromptRun = { before: Set; messages: Message[] } + +const activeApps = new Set() + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function basic(server: Server) { + return `Basic ${Buffer.from(`${server.username}:${server.password}`).toString("base64")}` +} + +function subagentState(session: Session) { + const deepagent = isRecord(session.metadata?.deepagent) ? session.metadata.deepagent : undefined + const subagent = isRecord(deepagent?.subagent) ? deepagent.subagent : undefined + return { + state: typeof subagent?.state === "string" ? subagent.state : undefined, + reason: typeof subagent?.reason === "string" ? subagent.reason : undefined, + } +} + +async function request(runtime: Runtime, pathname: string, init?: RequestInit) { + const url = new URL(pathname, runtime.server.url) + url.searchParams.set("directory", runtime.workspace) + const response = await fetch(url, { + ...init, + headers: { + authorization: basic(runtime.server), + ...(init?.body ? { "content-type": "application/json" } : {}), + ...init?.headers, + }, + }) + if (!response.ok) throw new Error(`${init?.method ?? "GET"} ${url.pathname} failed with HTTP ${response.status}`) + if (response.status === 204) return undefined as T + return (await response.json()) as T +} + +async function launch(name: string) { + const root = await realpath(await mkdtemp(join(tmpdir(), `deepagent-code-subagents-live-${name}-`))) + const workspace = join(root, "workspace") + await mkdir(workspace, { recursive: true }) + const env = Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ) + env.DEEPAGENT_CODE_TEST_ONBOARDING = "1" + env.DEEPAGENT_CODE_TEST_ROOT = root + env.DEEPAGENT_CODE_DB = join(root, "deepagent.sqlite") + env.DEEPAGENT_CODE_DISABLE_CHANNEL_DB = "1" + env.DEEPAGENT_CODE_DISABLE_EXTERNAL_SKILLS = "1" + env.DEEPAGENT_CODE_DISABLE_LSP_DOWNLOAD = "1" + env.DEEPAGENT_CODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS = "true" + env.DEEPAGENT_CODE_AUTH_CONTENT = JSON.stringify({ deepseek: deepseekAuth }) + env.DEEPAGENT_CODE_CONFIG_CONTENT = JSON.stringify({ + model: `${MODEL.providerID}/${MODEL.modelID}`, + permission: { task: "allow", bash: "allow", read: "allow" }, + }) + + const app = await electron.launch({ args: [main], env, timeout: 30_000 }) + activeApps.add(app) + const page = await app.firstWindow({ timeout: 30_000 }) + await page.waitForFunction(() => Boolean((window as unknown as { api?: DesktopAPI }).api)) + const server = await page.evaluate(() => (window as unknown as { api: DesktopAPI }).api.awaitInitialization()) + return { app, root, workspace, server } satisfies Runtime +} + +type DesktopAPI = { + awaitInitialization(): Promise +} + +async function close(runtime: Runtime) { + await runtime.app.close() + activeApps.delete(runtime.app) + if (process.env.DEEPAGENT_CODE_KEEP_LIVE_SMOKE !== "1") { + await rm(runtime.root, { recursive: true, force: true }) + } +} + +async function waitFor(check: () => Promise, label: string, timeout = 300_000): Promise { + const deadline = Date.now() + timeout + while (Date.now() < deadline) { + const value = await check() + if (value !== undefined) return value + await new Promise((resolve) => setTimeout(resolve, 250)) + } + throw new Error(`Timed out waiting for ${label}`) +} + +async function createParent(runtime: Runtime, title: string) { + return request(runtime, "/session", { + method: "POST", + body: JSON.stringify({ + title, + agent: "auto", + model: { id: MODEL.modelID, providerID: MODEL.providerID }, + }), + }) +} + +function messages(runtime: Runtime, sessionID: string) { + return request(runtime, `/session/${sessionID}/message`) +} + +function children(runtime: Runtime, sessionID: string) { + return request(runtime, `/session/${sessionID}/children`) +} + +async function startPrompt(runtime: Runtime, sessionID: string, text: string) { + const before = new Set((await messages(runtime, sessionID)).map((message) => message.info.id)) + await request(runtime, `/session/${sessionID}/prompt_async`, { + method: "POST", + body: JSON.stringify({ + model: MODEL, + agent: "auto", + system: + "You are controlling a live subagent E2E test. Follow the requested tool sequence exactly. Do not simulate tool output.", + parts: [{ type: "text", text }], + }), + }) + return before +} + +async function finishPrompt(runtime: Runtime, sessionID: string, before: Set, label: string) { + const fresh = await waitFor(async () => { + const [all, statuses] = await Promise.all([ + messages(runtime, sessionID), + request>(runtime, "/session/status"), + ]) + const next = all.filter((message) => !before.has(message.info.id)) + const active = statuses[sessionID]?.type + const completed = next.some( + (message) => message.info.role === "assistant" && message.info.time.completed !== undefined, + ) + if (completed && active !== "busy" && active !== "retry") return next + }, label) + return { before, messages: fresh } satisfies PromptRun +} + +async function runPrompt(runtime: Runtime, sessionID: string, text: string, label: string) { + return finishPrompt(runtime, sessionID, await startPrompt(runtime, sessionID, text), label) +} + +function toolParts(run: PromptRun, name: string) { + return run.messages.flatMap((message) => + message.parts.filter((part): part is ToolPart => part.type === "tool" && part.tool === name), + ) +} + +function completedTool(run: PromptRun, name: string) { + const parts = toolParts(run, name) + const completed = parts.findLast((part) => part.state.status === "completed") + if (completed?.state.status === "completed") return completed.state + const failed = parts.findLast((part) => part.state.status === "error") + throw new Error(`${name} did not complete${failed?.state.status === "error" ? `: ${failed.state.error}` : ""}`) +} + +function text(run: PromptRun) { + return run.messages + .flatMap((message) => + message.parts + .filter( + (part): part is Extract => + part.type === "text" && !part.synthetic && !part.ignored, + ) + .map((part) => part.text), + ) + .join("\n") +} + +function assertDeepSeek(messages: Message[]) { + assert.equal( + messages.some( + (message) => + message.info.role === "assistant" && + message.info.providerID === MODEL.providerID && + message.info.modelID === MODEL.modelID, + ), + true, + ) +} + +async function successScenario() { + const runtime = await launch("success") + try { + const marker = `LIVE_SUCCESS_${randomUUID().replaceAll("-", "")}` + const fixture = join(runtime.workspace, "success-fixture.txt") + await writeFile(fixture, `${marker}\n`, "utf8") + const parent = await createParent(runtime, "Live DeepSeek subagent success") + const taskRun = await runPrompt( + runtime, + parent.id, + [ + "Call the task tool exactly once and wait for it to finish.", + 'Use subagent_type="researcher" and description="live success fixture".', + `The child prompt must require reading ${fixture} and returning its exact content ${marker} in the final research result.`, + "Do not read the file yourself and do not call task_status or task_read yet.", + `After task returns, include ${marker} in your final response.`, + ].join("\n"), + "successful parent task", + ) + assertDeepSeek(taskRun.messages) + assert.match(completedTool(taskRun, "task").output, new RegExp(marker)) + assert.match(text(taskRun), new RegExp(marker)) + + const child = await waitFor(async () => { + const items = await children(runtime, parent.id) + const item = items.find((session) => subagentState(session).state === "completed") + return item + }, "completed child session") + const childMessages = await messages(runtime, child.id) + assertDeepSeek(childMessages) + assert.match(JSON.stringify(childMessages), new RegExp(marker)) + + const audit = await runPrompt( + runtime, + parent.id, + [ + `The completed child task id is ${child.id}.`, + "Call task_status exactly once, then call task_read exactly once with that task id.", + "Do not call task again and do not infer the transcript.", + `Your final response must include ${marker} and say that the child state is completed.`, + ].join("\n"), + "successful task audit", + ) + const status = completedTool(audit, "task_status").output + const transcript = completedTool(audit, "task_read").output + assert.match(status, /\[completed\]/) + assert.match(status, new RegExp(child.id)) + assert.match(transcript, /state="completed"/) + assert.match(transcript, new RegExp(marker)) + assert.match(text(audit), new RegExp(marker)) + console.log("Live success scenario passed", { parent: parent.id, child: child.id, state: "completed" }) + } finally { + await close(runtime) + } +} + +async function interruptedScenario() { + const runtime = await launch("interrupted") + try { + const marker = `LIVE_PARTIAL_${randomUUID().replaceAll("-", "")}` + const fixture = join(runtime.workspace, "partial-fixture.txt") + await writeFile(fixture, `${marker}\n`, "utf8") + const parent = await createParent(runtime, "Live DeepSeek subagent interruption") + const before = await startPrompt( + runtime, + parent.id, + [ + "Call the task tool exactly once in foreground mode and wait for it.", + 'Use subagent_type="researcher" and description="live interruption fixture".', + "Give the child these strict instructions:", + `1. Read ${fixture} with the read tool so its completed tool output contains ${marker}.`, + "2. After the read completes, run `sleep 120` with the bash tool.", + "3. Do not return a final answer before the sleep completes.", + "Do not call task_status or task_read yet.", + ].join("\n"), + ) + const child = await waitFor(async () => (await children(runtime, parent.id))[0], "interruption child session") + await waitFor(async () => { + const items = await messages(runtime, child.id) + const recovered = items.some((message) => + message.parts.some( + (part) => + part.type === "tool" && + part.tool === "read" && + part.state.status === "completed" && + part.state.output.includes(marker), + ), + ) + return recovered ? true : undefined + }, "recoverable child output") + await request(runtime, `/session/${child.id}/abort`, { method: "POST" }) + const taskRun = await finishPrompt(runtime, parent.id, before, "interrupted parent task") + assertDeepSeek(taskRun.messages) + assert.equal( + toolParts(taskRun, "task").some((part) => part.state.status === "error"), + true, + ) + + const settled = await waitFor(async () => { + const item = (await children(runtime, parent.id)).find((session) => session.id === child.id) + return item && subagentState(item).state ? item : undefined + }, "interrupted child settlement") + assert.equal(subagentState(settled).state, "interrupted") + + const audit = await runPrompt( + runtime, + parent.id, + [ + `The interrupted child task id is ${child.id}.`, + "Call task_status exactly once, then call task_read exactly once with that task id.", + "Do not call task again. Recover the partial work only from the tool transcript.", + `Your final response must include ${marker} and say that the child state is interrupted.`, + ].join("\n"), + "interrupted task audit", + ) + const status = completedTool(audit, "task_status").output + const transcript = completedTool(audit, "task_read").output + assert.match(status, /\[interrupted\]/) + assert.match(status, /partial work preserved/) + assert.match(transcript, /state="interrupted"/) + assert.match(transcript, new RegExp(marker)) + assert.match(text(audit), new RegExp(marker)) + console.log("Live interruption scenario passed", { parent: parent.id, child: child.id, state: "interrupted" }) + } finally { + await close(runtime) + } +} + +try { + await successScenario() + await interruptedScenario() + console.log("Live DeepSeek subagent E2E smoke passed") +} finally { + await Promise.all([...activeApps].map((app) => app.close().catch(() => undefined))) +} diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 777d3e41..034b9dbb 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -40,7 +40,7 @@ import { createWslServersController } from "./wsl/servers" import { registerWslIpcHandlers } from "./wsl/ipc" import { spawnWslSidecar } from "./wsl/sidecar" import { migrate } from "./migrate" -import { startPowerSaveBlocker, stopPowerSaveBlocker } from "./power" +import { initPowerSaveBlocker, stopPowerSaveBlocker } from "./power" import { createTray, destroyTray } from "./tray" const APP_NAMES: Record = { @@ -303,11 +303,12 @@ const main = Effect.gen(function* () { }) } - // Keep the app running (prevent idle sleep/hibernate) while it is active. The tray icon enables + // Keep the app running (prevent idle sleep/hibernate) while it is active, unless the user opted + // out in settings. The tray icon enables // close-to-tray: closing the window hides it to the tray with a right-click “Quit” to fully exit. // On platforms without tray support (e.g. Linux GNOME default), tray creation fails silently and // close-to-tray stays disabled so closing the window quits normally — avoiding a stranded window. - startPowerSaveBlocker() + initPowerSaveBlocker() const trayCreated = createTray(() => mainWindow) setCloseToTrayEnabled(trayCreated) diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 9d8b4ce4..d3fca7f3 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -12,6 +12,8 @@ import { archivePath, copyPath, extractPath, guardFileOpCall, movePath, removePa import { fileLog, isTracked } from "./git" import { getStore } from "./store" import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows" +import { getPreventSleepEnabled, setPreventSleepEnabled, syncPowerSaveBlocker } from "./power" +import { PREVENT_SLEEP_KEY, SETTINGS_STORE } from "./store-keys" import { browserView } from "./browser-view" import type { UpdaterController } from "./updater-controller" import { createUpdaterSubscriptions } from "./updater-subscriptions" @@ -92,13 +94,21 @@ export function registerIpcHandlers(deps: Deps) { } }) ipcMain.handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => { + // preventSleep drives a live powerSaveBlocker side effect; routing writes through the + // dedicated set-prevent-sleep IPC keeps the blocker in sync with the persisted value. + if (name === SETTINGS_STORE && key === PREVENT_SLEEP_KEY) { + throw new Error(`"${PREVENT_SLEEP_KEY}" must be written via the set-prevent-sleep IPC`) + } getStore(name).set(key, value) }) ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => { getStore(name).delete(key) + // Deleting the key resets it to the default-on behavior; re-sync so the blocker matches. + if (name === SETTINGS_STORE && key === PREVENT_SLEEP_KEY) syncPowerSaveBlocker() }) ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => { getStore(name).clear() + if (name === SETTINGS_STORE) syncPowerSaveBlocker() }) ipcMain.handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => { const store = getStore(name) @@ -246,6 +256,13 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle("set-pinch-zoom-enabled", (_event: IpcMainInvokeEvent, enabled: boolean) => { setPinchZoomEnabled(enabled) }) + ipcMain.handle("get-prevent-sleep", () => getPreventSleepEnabled()) + ipcMain.handle("set-prevent-sleep", (_event: IpcMainInvokeEvent, enabled: boolean) => { + setPreventSleepEnabled(enabled) + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send("prevent-sleep-changed", enabled) + } + }) ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => { const win = BrowserWindow.fromWebContents(event.sender) if (!win) return diff --git a/packages/desktop/src/main/power.test.ts b/packages/desktop/src/main/power.test.ts new file mode 100644 index 00000000..16aa5518 --- /dev/null +++ b/packages/desktop/src/main/power.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test" +import { resolvePreventSleepEnabled } from "./prevent-sleep" + +// Covers the persisted-setting semantics of the sleep-prevention toggle without the Electron +// runtime. The same resolution runs in power.ts on startup and on every settings change. + +describe("resolvePreventSleepEnabled", () => { + test("defaults to enabled when the setting was never stored (historical always-on behavior)", () => { + expect(resolvePreventSleepEnabled(undefined)).toBe(true) + }) + + test("treats any value other than explicit false as enabled", () => { + expect(resolvePreventSleepEnabled(true)).toBe(true) + expect(resolvePreventSleepEnabled(null)).toBe(true) + expect(resolvePreventSleepEnabled("false")).toBe(true) + }) + + test("disables only on explicit false", () => { + expect(resolvePreventSleepEnabled(false)).toBe(false) + }) +}) diff --git a/packages/desktop/src/main/power.ts b/packages/desktop/src/main/power.ts index aa8c6121..bd7c2db7 100644 --- a/packages/desktop/src/main/power.ts +++ b/packages/desktop/src/main/power.ts @@ -1,9 +1,37 @@ import { powerSaveBlocker } from "electron" +import { resolvePreventSleepEnabled } from "./prevent-sleep" +import { getStore } from "./store" +import { PREVENT_SLEEP_KEY } from "./store-keys" + // prevent-app-suspension keeps the system/app from idling to sleep while allowing the display to // turn off (go dark) and the lid to close — i.e. it blocks idle sleep & hibernate, not screen blank. let blockerId: number | null = null +export function getPreventSleepEnabled(): boolean { + return resolvePreventSleepEnabled(getStore().get(PREVENT_SLEEP_KEY)) +} + +export function setPreventSleepEnabled(enabled: boolean): void { + getStore().set(PREVENT_SLEEP_KEY, enabled) + syncPowerSaveBlocker() +} + +// Reconciles the running blocker with the persisted setting. Must be called after any write path +// that can change the stored value outside setPreventSleepEnabled (generic store delete/clear). +export function syncPowerSaveBlocker(): void { + if (getPreventSleepEnabled()) { + startPowerSaveBlocker() + return + } + stopPowerSaveBlocker() +} + +export function initPowerSaveBlocker(): void { + if (!getPreventSleepEnabled()) return + startPowerSaveBlocker() +} + export function startPowerSaveBlocker(): void { if (blockerId !== null) return blockerId = powerSaveBlocker.start("prevent-app-suspension") diff --git a/packages/desktop/src/main/prevent-sleep.ts b/packages/desktop/src/main/prevent-sleep.ts new file mode 100644 index 00000000..d90a830d --- /dev/null +++ b/packages/desktop/src/main/prevent-sleep.ts @@ -0,0 +1,6 @@ +// Sleep prevention predates the settings toggle, so an unset key must keep the historical +// always-on behavior; only an explicit `false` opts out. Kept Electron-free so the decision +// can be unit-tested without the runtime (same split as close-to-tray.ts). +export function resolvePreventSleepEnabled(stored: unknown): boolean { + return stored !== false +} diff --git a/packages/desktop/src/main/store-keys.ts b/packages/desktop/src/main/store-keys.ts index beb18b8b..85ae29c0 100644 --- a/packages/desktop/src/main/store-keys.ts +++ b/packages/desktop/src/main/store-keys.ts @@ -2,3 +2,4 @@ export const SETTINGS_STORE = "deepagent-code.settings" export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl" export const WSL_SERVERS_KEY = "wslServers" export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled" +export const PREVENT_SLEEP_KEY = "preventSleep" diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 1c83db5a..108f4953 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -122,6 +122,13 @@ const api: ElectronAPI = { ipcRenderer.on("pinch-zoom-enabled-changed", handler) return () => ipcRenderer.removeListener("pinch-zoom-enabled-changed", handler) }, + getPreventSleepEnabled: () => ipcRenderer.invoke("get-prevent-sleep"), + setPreventSleepEnabled: (enabled) => ipcRenderer.invoke("set-prevent-sleep", enabled), + onPreventSleepChanged: (cb) => { + const handler = (_: unknown, enabled: boolean) => cb(enabled) + ipcRenderer.on("prevent-sleep-changed", handler) + return () => ipcRenderer.removeListener("prevent-sleep-changed", handler) + }, onZoomFactorChanged: (cb) => { const handler = (_: unknown, factor: number) => cb(factor) ipcRenderer.on("zoom-factor-changed", handler) diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 3d882a6b..db0b741c 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -123,6 +123,9 @@ export type ElectronAPI = { getPinchZoomEnabled: () => Promise setPinchZoomEnabled: (enabled: boolean) => Promise onPinchZoomEnabledChanged: (cb: (enabled: boolean) => void) => () => void + getPreventSleepEnabled: () => Promise + setPreventSleepEnabled: (enabled: boolean) => Promise + onPreventSleepChanged: (cb: (enabled: boolean) => void) => () => void onZoomFactorChanged: (cb: (factor: number) => void) => () => void setTitlebar: (theme: TitlebarTheme) => Promise runDesktopMenuAction: (action: DesktopMenuAction) => Promise diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 33b95d1d..9f4b56cc 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -265,6 +265,10 @@ const createPlatform = (): Platform => { setPinchZoomEnabled, + getPreventSleepEnabled: () => window.api.getPreventSleepEnabled(), + + setPreventSleepEnabled: (enabled: boolean) => window.api.setPreventSleepEnabled(enabled), + runDesktopMenuAction, checkAppExists: async (appName: string) => {