diff --git a/packages/app/src/components/deepagent/goal-start-button.tsx b/packages/app/src/components/deepagent/goal-start-button.tsx index 9a33b798..89a9bb9d 100644 --- a/packages/app/src/components/deepagent/goal-start-button.tsx +++ b/packages/app/src/components/deepagent/goal-start-button.tsx @@ -3,9 +3,17 @@ import { Button } from "@deepagent-code/ui/button" import { Icon } from "@deepagent-code/ui/icon" import { useServerSync } from "@/context/server-sync" import { useSDK } from "@/context/sdk" +import { useLocal } from "@/context/local" import { useLanguage } from "@/context/language" import { showToast } from "@/utils/toast" -import { fetchCapabilities, startGoal, type PanelGoalClient } from "./panel-goal.api" +import { fetchCapabilities, fetchGoalStartable, startGoal, type PanelGoalClient } from "./panel-goal.api" + +// The collaboration modes where "convert plan → supervised goal" makes sense. loop/design are BOTH +// powered by the Goal Loop engine and are designed to have the human START the loop after the plan is +// authored (loop: agent writes goal+plan.md; design: user writes it). auto is autonomous end-to-end in +// the current turn — a supervised background goal would be a confusing, redundant second door there, so +// the button must NOT appear in auto. plan is hidden and never the client-visible current mode. +const GOAL_MODES = new Set(["loop", "design"]) /** * V3.9 §D — "convert plan → goal" starter. @@ -34,6 +42,7 @@ function errorMessage(err: unknown): string { export function GoalStartButton(props: { sessionID: string }) { const sdk = useSDK() const serverSync = useServerSync() + const local = useLocal() const language = useLanguage() const [busy, setBusy] = createSignal(false) @@ -45,14 +54,31 @@ export function GoalStartButton(props: { sessionID: string }) { ) const goalAvailable = createMemo(() => capabilities()?.goalLoop === true) - const plan = createMemo(() => (props.sessionID ? serverSync.data.session_plan[props.sessionID] : undefined)) - const hasPlan = createMemo(() => (plan()?.steps.length ?? 0) > 0) + // The current collaboration mode (auto/loop/design) — the button only applies to loop/design. Sourced + // from local.agent.current() (session-scoped mode selection), the same source the mode selector uses. + const currentMode = createMemo(() => local.agent.current()?.name) + const modeAllows = createMemo(() => GOAL_MODES.has(currentMode() ?? "")) - // A goal is "live" for this session iff the persistent session_goal pointer exists and is not in a - // terminal phase the user has dismissed — while present, GoalStatusBar owns the surface. + // A goal is "live" for this session iff the persistent session_goal pointer exists — while present, + // GoalStatusBar owns the surface. Checked FIRST so we don't probe startability for an already-running + // goal. const activeGoal = createMemo(() => (props.sessionID ? serverSync.data.session_goal[props.sessionID] : undefined)) - const show = createMemo(() => goalAvailable() && hasPlan() && !activeGoal()) + // Whether a plan actually exists to start, resolved server-side (session_plan OR repo goal+plan.md). + // Re-fetched when the session, mode-eligibility, active-goal, or the in-session plan changes — the last + // dependency makes the button appear promptly after the agent writes a plan mid-conversation. Only + // probed when the mode allows and no goal is live, to avoid needless requests. + const [startable] = createResource( + () => + props.sessionID && goalAvailable() && modeAllows() && !activeGoal() + ? ([props.sessionID, serverSync.data.session_plan[props.sessionID]?.steps.length ?? 0] as const) + : undefined, + ([sessionID]) => fetchGoalStartable(client(), sessionID), + ) + + const show = createMemo( + () => goalAvailable() && modeAllows() && !activeGoal() && startable()?.startable === true, + ) const onStart = async () => { if (busy() || !props.sessionID) return @@ -61,8 +87,11 @@ export function GoalStartButton(props: { sessionID: string }) { const snapshot = await startGoal(client(), { sessionID: props.sessionID }) if (!snapshot) { showToast({ title: language.t("goal.start.failed") }) + } else { + // The server emits an immediate goal.updated (phase=running) on start, so GoalStatusBar takes + // over this surface right away. This toast is the belt-and-suspenders confirmation for the click. + showToast({ title: language.t("goal.start.success"), variant: "success" }) } - // On success the goal.updated event drives GoalStatusBar to appear; nothing to do here. } catch (err) { showToast({ title: language.t("goal.start.failed"), description: errorMessage(err) }) } finally { diff --git a/packages/app/src/components/deepagent/panel-goal.api.ts b/packages/app/src/components/deepagent/panel-goal.api.ts index 65c1f34a..86859e74 100644 --- a/packages/app/src/components/deepagent/panel-goal.api.ts +++ b/packages/app/src/components/deepagent/panel-goal.api.ts @@ -175,3 +175,26 @@ export const goalStatus = async ( }) return response.data?.goal ?? null } + +export type GoalStartable = { startable: boolean; source: "plan" | "file" | "none" } + +/** + * Whether a goal can be started for this session right now, resolved SERVER-SIDE with the same plan + * precedence start() uses (session_plan → repo goal+plan.md → none). The button gates on this instead + * of reading session_plan directly, because loop/design modes author the plan as the repo file (never + * touching session_plan), so a client-only hasPlan() check would hide the button in exactly the modes + * where it belongs. Tolerant of an older server that lacks the route (treated as not-startable). + */ +export const fetchGoalStartable = async ( + client: PanelGoalClient, + sessionID: string, +): Promise => { + const response = await client.client.request({ + method: "GET", + url: `/deepagent/goal/startable?sessionID=${encodeURIComponent(sessionID)}`, + }) + return { + startable: response.data?.startable ?? false, + source: response.data?.source ?? "none", + } +} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 42a8f0fc..9485d9d6 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -932,6 +932,7 @@ export const dict = { "sidebar.wiki": "Repo & Wiki", "goal.start.hint": "Plan ready — run it as a supervised goal", "goal.start.button": "Run as goal", + "goal.start.success": "Goal started — running in the background", "goal.start.failed": "Couldn't start the goal", "wiki.title": "Repo & Wiki", "wiki.description": "Read, search, and govern the four graphs. Knowledge and Memory are editable; Documents and Code are read-only.", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index c5ac0d8a..bac8c91c 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -793,6 +793,7 @@ export const dict = { "sidebar.wiki": "仓库与百科", "goal.start.hint": "计划已就绪 — 转为受监督的长跑目标", "goal.start.button": "转为 Goal", + "goal.start.success": "目标已启动,正在后台运行", "goal.start.failed": "无法启动目标", "wiki.title": "仓库与百科", "wiki.description": "阅读、检索、治理四张图。知识与记忆可编辑;文档与代码只读。", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 10a458a1..86831e26 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -646,6 +646,7 @@ export const dict = { "review.scope.global": "全域", "goal.start.hint": "計劃已就緒 — 轉為受監督的長跑目標", "goal.start.button": "轉為 Goal", + "goal.start.success": "目標已啟動,正在背景執行", "goal.start.failed": "無法啟動目標", "wiki.title": "倉庫與百科", "wiki.description": "閱讀、檢索、治理四張圖。知識與記憶可編輯;文件與程式碼唯讀。", diff --git a/packages/core/src/deepagent/command-intent.ts b/packages/core/src/deepagent/command-intent.ts new file mode 100644 index 00000000..aaee1070 --- /dev/null +++ b/packages/core/src/deepagent/command-intent.ts @@ -0,0 +1,291 @@ +export * as CommandIntent from "./command-intent" + +// Command intent classification for the Plan Gate (U1/U9). The gate soft/hard-blocks MUTATING tools +// while the plan latch is stale, but a shell command that only INSPECTS the world (ls, cat, grep, +// git status, curl probes, …) must never be blocked — those are the agent's eyes, and blocking them +// would make a stale plan impossible to diagnose and repair. This module decides whether a shell +// command string is provably read-only. +// +// FAIL-SAFE CONTRACT (load-bearing): this classifier is used to RELAX the gate, so it must never +// misclassify a mutating command as read-only. Any ambiguity — an unknown command, an unparseable +// segment, a redirection, a write-capable operator, elevated privilege — resolves to `mutating`. +// It is acceptable (merely slightly conservative) to classify a read-only command as mutating; it is +// NOT acceptable to classify a mutating command as read-only. Every branch below is written so the +// default answer is `mutating`. +// +// This is a pure, lexical analyzer (no tree-sitter): core has no shell-parser dependency, and a +// lexical pass is the right tool for a fail-safe gate — it cannot "successfully parse" a hostile +// command into a benign shape. deepagent-code's shell tool keeps its own tree-sitter path for +// permission scanning; this is deliberately independent and stricter. + +export type CommandIntent = "read_only" | "mutating" + +// Prefix → arity: how many leading tokens define the "command" for a read-only match. Flags between +// the command word and its subcommand are skipped by the matcher, so `git --no-pager status` still +// matches the `git status` (arity 2) entry. Only commands that CANNOT mutate the filesystem, process +// table, network state, or environment in a persistent way belong here. When in doubt, leave it out. +const READ_ONLY_PREFIXES: ReadonlyArray = [ + // ── filesystem inspection ── + ["ls"], + ["cat"], + ["bat"], + ["head"], + ["tail"], + ["wc"], + ["file"], + ["stat"], + ["du"], + ["df"], + ["tree"], + ["realpath"], + ["readlink"], + ["basename"], + ["dirname"], + ["pwd"], + ["find"], // read-only UNLESS it carries an action flag (guarded separately below) + // ── content search ── + ["grep"], + ["egrep"], + ["fgrep"], + ["rg"], + ["ag"], + ["ripgrep"], + // ── environment / introspection (query forms only) ── + ["which"], + ["whereis"], + ["type"], + ["echo"], + ["printf"], + ["date"], + ["whoami"], + ["id"], + ["hostname"], + ["uname"], + ["env"], // read-only ONLY with no assignment/command args (guarded below) + ["printenv"], + ["locale"], + ["uptime"], + ["ps"], + ["top"], + ["htop"], + ["free"], + ["df"], + ["lsof"], + ["jobs"], + ["history"], + // ── version / help probes ── + ["node", "--version"], + ["node", "-v"], + ["python", "--version"], + ["python3", "--version"], + ["go", "version"], + ["rustc", "--version"], + ["java", "-version"], + // ── git (read-only subcommands only) ── + ["git", "status"], + ["git", "log"], + ["git", "diff"], + ["git", "show"], + ["git", "branch"], + ["git", "tag"], + ["git", "remote"], + ["git", "rev-parse"], + ["git", "rev-list"], + ["git", "describe"], + ["git", "blame"], + ["git", "ls-files"], + ["git", "ls-remote"], + ["git", "cat-file"], + ["git", "config", "--get"], + ["git", "config", "--list"], + ["git", "config", "-l"], + ["git", "shortlog"], + ["git", "reflog"], + ["git", "whatchanged"], + // ── container / orchestration (query verbs only) ── + ["docker", "ps"], + ["docker", "images"], + ["docker", "logs"], + ["docker", "inspect"], + ["docker", "version"], + ["docker", "info"], + ["kubectl", "get"], + ["kubectl", "describe"], + ["kubectl", "logs"], + ["kubectl", "version"], + // ── package-manager query verbs ── + ["npm", "ls"], + ["npm", "list"], + ["npm", "view"], + ["npm", "outdated"], + ["pip", "list"], + ["pip", "show"], + ["pip", "freeze"], + ["cargo", "tree"], + ["brew", "list"], + ["brew", "info"], + // ── network probes (read-only unless they write a file; guarded below) ── + ["curl"], // mutating if it carries -o/-O/--output/--remote-name (guarded) +] + +// After fd-duplication spans (2>&1, 1>&2, >&2) are stripped, ANY remaining `>` is a file-writing +// redirection (> truncate, >> append, <> read-write), which makes the segment mutating. Input `<` +// alone is not mutating. We check for a bare `>` on the stripped text. + +// Command words that are inherently mutating no matter their flags. Fast reject before prefix match. +const MUTATING_COMMANDS = new Set([ + "rm", + "rmdir", + "mv", + "cp", + "dd", + "mkdir", + "touch", + "ln", + "chmod", + "chown", + "chgrp", + "truncate", + "shred", + "tee", + "install", + "sed", // sed alone is read-only, but `sed -i` mutates; treat as mutating unless we prove otherwise (guarded) + "kill", + "killall", + "pkill", + "reboot", + "shutdown", + "mkfs", + "mount", + "umount", + "export", + "unset", + "set", + "source", + ".", + "eval", + "exec", + "apt", + "apt-get", + "yum", + "dnf", + "pacman", + "systemctl", + "service", + "crontab", +]) + +// Extract the shell segments that run as their own command. We split on the operators that separate +// commands: && || ; | & (background) and newlines. This is intentionally coarse: a segment that +// contains anything we cannot prove read-only makes the WHOLE command mutating. +const SEGMENT_SPLIT = /(?:&&|\|\||[;\n|&])/ + +// Lexical tokenizer that keeps quoted spans intact (mirrors the tokenizer in tool/bash.ts). +const tokenize = (segment: string): string[] => segment.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [] + +const unquote = (value: string) => value.replace(/^(['"])(.*)\1$/, "$2") + +// A token is a "flag" if it begins with `-` (short/long option). +const isFlag = (token: string) => token.startsWith("-") + +// Does the segment's leading command match a read-only prefix? Returns the matched prefix length (in +// tokens consumed), or 0 if no read-only prefix matches. Intervening flags that are NOT part of the +// prefix are skipped, so `git --no-pager status` still resolves to `git status`. A flag that IS the +// next expected prefix component (e.g. the `--version` in `node --version`, the `--get` in +// `git config --get`) is matched normally rather than skipped. +const matchReadOnlyPrefix = (tokens: string[]): number => { + const words = tokens.map(unquote) + for (const prefix of READ_ONLY_PREFIXES) { + let matched = 0 + let tokenCursor = 0 + for (; tokenCursor < words.length && matched < prefix.length; tokenCursor++) { + const token = words[tokenCursor] + if (token === prefix[matched]) { + matched++ + continue + } + // Skip a non-matching flag only when the expected component is itself NOT a flag — otherwise a + // flag-typed prefix component (--version/--get) could be silently skipped past. + if (isFlag(token) && !isFlag(prefix[matched])) continue + break + } + if (matched === prefix.length) return tokenCursor + } + return 0 +} + +// `find` is read-only unless it carries an action predicate that executes or deletes. +const FIND_MUTATING_ACTIONS = new Set(["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprintf"]) + +// `env` is read-only only as a bare query (`env`); `env FOO=bar cmd` runs a command with a mutated +// environment, so anything past the command word makes it mutating. +const isReadOnlySegment = (segment: string): boolean => { + const trimmed = segment.trim() + if (trimmed === "") return true // empty segment (e.g. trailing operator) is inert + + // Any output redirection in the segment → mutating. Strip fd-duplication (2>&1, 1>&2, >&2) first, + // which is not a file write, then look for a real `>`. + const withoutFdDup = trimmed.replace(/\d*>&\d*/g, " ") + if (withoutFdDup.includes(">")) return false + + const tokens = tokenize(trimmed) + if (tokens.length === 0) return false + + // Command substitution / process substitution / backticks can smuggle an arbitrary command — we + // cannot prove those read-only lexically, so fail safe. + if (/\$\(|<\(|>\(|`/.test(trimmed)) return false + + const head = unquote(tokens[0]) + + // A leading `VAR=value` assignment prefix (env inline) means a command runs with a mutated env, or + // the assignment itself persists in the shell — fail safe. + if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(head)) return false + + // Inherently mutating command word → mutating. + if (MUTATING_COMMANDS.has(head)) return false + + const prefixLen = matchReadOnlyPrefix(tokens) + if (prefixLen === 0) return false // unknown/unlisted command → fail safe to mutating + + // Command-specific guards for read-only prefixes that have mutating variants. + const words = tokens.map(unquote) + if (head === "find" && words.some((token) => FIND_MUTATING_ACTIONS.has(token))) return false + if (head === "env") { + // Only a bare `env` (optionally with -i/-u flags but no command/assignment) is read-only. + const rest = words.slice(1).filter((token) => !isFlag(token)) + if (rest.length > 0) return false + } + if (head === "curl" && /\s-[oO]\b|--output\b|--remote-name\b/.test(trimmed)) return false + + return true +} + +/** + * Classify a shell command string as read-only or mutating for the Plan Gate. + * + * The command is split into segments on shell command separators; EVERY segment must be provably + * read-only for the whole command to be read-only. Any segment that is mutating, unknown, or + * unparseable makes the entire command `mutating` (fail-safe). + */ +export const classifyCommand = (command: string): CommandIntent => { + if (typeof command !== "string" || command.trim() === "") return "mutating" + // Mask fd-duplication spans (2>&1, 1>&2, >&2) before splitting so their internal `&` is not + // mistaken for a background/`&&` command separator. Spans are captured by index and restored + // exactly per-segment; the placeholder holds no separator or `>` char so it survives the split, + // and isReadOnlySegment strips fd-dup again defensively. + const fdSpans: string[] = [] + const masked = command.replace(/\d*>&\d*/g, (m) => { + const token = " fd" + fdSpans.length + "fd " + fdSpans.push(m) + return token + }) + const restore = (segment: string) => segment.replace(/ fd(\d+)fd /g, (_, idx) => fdSpans[Number(idx)] ?? "") + const segments = masked.split(SEGMENT_SPLIT) + for (const rawSegment of segments) { + const segment = restore(rawSegment) + if (!isReadOnlySegment(segment)) return "mutating" + } + return "read_only" +} + +export const isReadOnlyCommand = (command: string): boolean => classifyCommand(command) === "read_only" diff --git a/packages/core/src/deepagent/hooks.ts b/packages/core/src/deepagent/hooks.ts index 58e3116e..f137ea4b 100644 --- a/packages/core/src/deepagent/hooks.ts +++ b/packages/core/src/deepagent/hooks.ts @@ -68,15 +68,31 @@ export const stopHookGate = (): HookHandler => (e) => { // U9 hard gate (high+): even with a fresh plan, a mutating tool must be BOUND to an active step. // hardGateMissBlocks=true (xhigh/max/ultra) -> block on a missing active step; false (high) -> warn. // The payload carries hardGate, hasActiveStep, hardGateMissBlocks. +// +// U1 anti-deadlock (production fix): the soft layer additionally downgrades a would-be block to a +// WARN (tool executes, reminder attached) in two cases, so the gate can never permanently deny a +// model its tools: +// - staleReason === "user_appended": a new user message is a SOFT signal that the plan MIGHT no +// longer match intent — nudge the model to re-align, but do not hard-block work the user is +// actively asking for. +// - graceRelease === true: the gate has already blocked this stale plan N times with no forward +// progress (runtime-driven counter). Rather than loop forever against a model that will not +// repair the plan, release with a strong reminder. This is the direct fix for the observed +// deadlock (280 consecutive blocked bash calls). export const planGate = (): HookHandler => (e) => { if (e.name !== "before_tool_use") return { decision: "continue" } if (e.payload["isMutating"] !== true) return { decision: "allow" } // U1 soft layer: stale plan. if (e.payload["planStale"] === true) { const reason = "the plan is stale (reality changed); update the plan before further edits" - return e.payload["lightweight"] === true - ? { decision: "warn", blockReason: reason } - : { decision: "block", blockReason: reason } + const graceReason = + "the plan is stale and has not been updated after repeated attempts; this action is allowed to proceed, but you MUST call the `plan` tool to resync your plan with reality now" + const userAppendedReason = + "a new user message arrived; your plan may no longer match the request — review it and update the `plan` tool if the goal changed" + if (e.payload["lightweight"] === true) return { decision: "warn", blockReason: reason } + if (e.payload["graceRelease"] === true) return { decision: "warn", blockReason: graceReason } + if (e.payload["staleReason"] === "user_appended") return { decision: "warn", blockReason: userAppendedReason } + return { decision: "block", blockReason: reason } } // U9 hard layer: per-step binding (high+ only; lightweight never reaches here with hardGate set). if (e.payload["hardGate"] === true && e.payload["hasActiveStep"] !== true) { diff --git a/packages/core/src/deepagent/plan-controller.ts b/packages/core/src/deepagent/plan-controller.ts index 94789951..fa393706 100644 --- a/packages/core/src/deepagent/plan-controller.ts +++ b/packages/core/src/deepagent/plan-controller.ts @@ -11,6 +11,7 @@ // models whose self-reporting is unreliable. import { randomUUID } from "node:crypto" import type { AgentMode } from "./mode" +import { classifyCommand } from "./command-intent" // Why the plan went stale. Each value maps 1:1 to a signal the live loop already produces, so the // latch can be flipped from runtime truth without any model cooperation (S1 U1 §plan_stale 触发源). @@ -65,6 +66,12 @@ export type PlanLatchState = { readonly latch: PlanLatch readonly stale_reason: StaleReason | null readonly replan_count: number + // Runtime-driven grace counter (U1 anti-deadlock). Incremented every time the gate BLOCKS a + // mutating tool while the plan is stale; reset to 0 whenever forward progress happens (the plan is + // updated with a real status change, or a mutating tool actually executes). Unlike replan_count — + // which only advances when the MODEL cooperates by calling the plan tool — this advances from the + // runtime alone, so the grace release can fire even against a model that never repairs the plan. + readonly consecutive_blocks: number } export const initialPlanLatch = (planId: string | null = null): PlanLatchState => ({ @@ -72,6 +79,7 @@ export const initialPlanLatch = (planId: string | null = null): PlanLatchState = latch: "fresh", stale_reason: null, replan_count: 0, + consecutive_blocks: 0, }) // Flip to stale (idempotent on reason). Pure: the caller persists. The runtime calls this from the @@ -80,19 +88,43 @@ export const markStale = (state: PlanLatchState, reason: StaleReason): PlanLatch state.latch === "stale" && state.stale_reason === reason ? state : { ...state, latch: "stale", stale_reason: reason } // Clear the latch after the plan was updated. Bumps replan_count so the escape hatch can fire if the -// model thrashes (keeps producing a plan that immediately goes stale again). +// model thrashes (keeps producing a plan that immediately goes stale again). Also resets the runtime +// grace counter, since updating the plan is genuine forward progress. export const clearStale = (state: PlanLatchState): PlanLatchState => state.latch === "fresh" ? state - : { ...state, latch: "fresh", stale_reason: null, replan_count: state.replan_count + 1 } + : { ...state, latch: "fresh", stale_reason: null, replan_count: state.replan_count + 1, consecutive_blocks: 0 } + +// U1 anti-deadlock: record that the gate just BLOCKED a mutating tool on a stale plan. Advances the +// runtime grace counter. Pure; the caller persists. +export const recordGateBlock = (state: PlanLatchState): PlanLatchState => ({ + ...state, + consecutive_blocks: state.consecutive_blocks + 1, +}) + +// U1 anti-deadlock: forward progress happened (a mutating tool executed), so the model is not stuck +// hammering a blocked gate. Reset the grace counter without touching the latch/replan bookkeeping. +export const resetGateBlocks = (state: PlanLatchState): PlanLatchState => + state.consecutive_blocks === 0 ? state : { ...state, consecutive_blocks: 0 } // Escape hatch (mirrors the existing no-progress -> needs_human pattern): after too many replans we // STOP forcing the model to update the plan and hand off to a human, instead of looping forever on a -// model that can't produce a stable plan. Default limit 3 (S1 U1). +// model that can't produce a stable plan. Default limit 3 (S1 U1). This is the MODEL-cooperative +// hatch: it only advances when the model actually re-plans. export const DEFAULT_REPLAN_LIMIT = 3 export const shouldEscapeToHuman = (state: PlanLatchState, limit: number = DEFAULT_REPLAN_LIMIT): boolean => state.replan_count > limit +// U1 anti-deadlock grace release: after the gate has blocked the SAME stale plan this many times +// without any forward progress, stop blocking and let the next mutating tool through (with a strong +// reminder injected by the caller). This is the RUNTIME-driven hatch — it fires without any model +// cooperation, so a model that never repairs the plan (e.g. one that degrades to giving the user +// manual commands instead of calling the plan tool) can never be permanently denied its tools. This +// is the direct fix for the production deadlock where 280 consecutive bash calls were blocked. +export const DEFAULT_GRACE_BLOCK_LIMIT = 3 +export const shouldGraceRelease = (state: PlanLatchState, limit: number = DEFAULT_GRACE_BLOCK_LIMIT): boolean => + state.consecutive_blocks >= limit + // general/direct take the lightweight path: the soft gate WARNS but never blocks, so ordinary // implement/debug/chat tasks are never slowed by the plan machinery (docs/38 §9). high+ get the real // soft block. @@ -105,13 +137,19 @@ export const isLightweightMode = (mode: AgentMode | string): boolean => mode === const MUTATING_TOOLS = new Set(["edit", "write", "patch", "apply_patch", "multiedit"]) const ALWAYS_ALLOWED_TOOLS = new Set(["read", "grep", "glob", "list", "ls", "search", "task", "webfetch"]) -export const isMutatingTool = (toolName: string): boolean => { +export const isMutatingTool = (toolName: string, command?: string | null): boolean => { const name = toolName.toLowerCase() if (ALWAYS_ALLOWED_TOOLS.has(name)) return false if (MUTATING_TOOLS.has(name)) return true - // bash/shell is mutating UNLESS the caller already classified it read-only (docs/38 §7.2 read-only - // default for deterministic queries). The payload may carry readOnly:true for `git status` etc. - if (name === "bash" || name === "shell") return true + // bash/shell is mutating UNLESS the command is provably read-only (docs/38 §7.2 read-only default + // for deterministic queries). A read-only shell command (ls/cat/grep/git status/curl probe/…) is + // the agent's eyes: it must NEVER be blocked by the plan gate, otherwise a stale plan could never + // be diagnosed and repaired. The classifier is fail-safe — any ambiguity resolves to mutating — + // so a missing/empty command (nothing to classify) is treated as mutating. + if (name === "bash" || name === "shell") { + if (command == null || command.trim() === "") return true + return classifyCommand(command) === "mutating" + } return false } diff --git a/packages/core/src/deepagent/session-state.ts b/packages/core/src/deepagent/session-state.ts index b8fa9c98..885733bd 100644 --- a/packages/core/src/deepagent/session-state.ts +++ b/packages/core/src/deepagent/session-state.ts @@ -21,6 +21,8 @@ import { initialPlanLatch, markStale, clearStale, + recordGateBlock, + resetGateBlocks, planStatusesChanged, type PlanLatchState, type StaleReason, @@ -289,6 +291,26 @@ export const clearPlanStale = (sessionId: string): void => { saveToDisk() } +// 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 => { + const state = sessions.get(sessionId) + if (!state) return + state.planLatch = recordGateBlock(state.planLatch) + saveToDisk() +} + +// U1 anti-deadlock: a mutating tool actually executed (forward progress), so reset the grace counter. +// No-op when already zero to avoid churning disk writes on the hot path. +export const resetPlanGateBlocks = (sessionId: string): void => { + const state = sessions.get(sessionId) + if (!state) return + const next = resetGateBlocks(state.planLatch) + if (next === state.planLatch) return + state.planLatch = next + saveToDisk() +} + export const planLatch = (sessionId: string): PlanLatchState | undefined => sessions.get(sessionId)?.planLatch export const complete = (sessionId: string): void => { @@ -358,8 +380,13 @@ function normalizeState(state: SessionRunState): SessionRunState { maxTotalTokens: nextBudget.maxTotalTokens, maxRounds: nextBudget.maxRounds, }, - // Backfill: sessions persisted before U1 have no planLatch/plan on disk. - planLatch: state.planLatch ?? initialPlanLatch(), + // Backfill: sessions persisted before U1 have no planLatch/plan on disk. Sessions persisted + // between U1 and the anti-deadlock change have a planLatch WITHOUT consecutive_blocks — backfill + // that field to 0 so shouldGraceRelease reads a defined counter (an undefined would make + // `>= limit` false forever, silently disabling the grace release for older sessions). + planLatch: state.planLatch + ? { ...state.planLatch, consecutive_blocks: state.planLatch.consecutive_blocks ?? 0 } + : initialPlanLatch(), plan: state.plan ?? null, // Backfill: sessions persisted before U10 have no counter on disk. mutationsSinceReport: state.mutationsSinceReport ?? 0, diff --git a/packages/core/test/deepagent/command-intent.test.ts b/packages/core/test/deepagent/command-intent.test.ts new file mode 100644 index 00000000..b6d192ec --- /dev/null +++ b/packages/core/test/deepagent/command-intent.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test } from "bun:test" +import { classifyCommand, isReadOnlyCommand } from "../../src/deepagent/command-intent" + +// The classifier is used to RELAX the plan gate, so its contract is FAIL-SAFE: it must never call a +// mutating command read-only. These tests pin down both directions — the read-only allow-list AND +// the mutating fail-safe — so a future edit that loosens the classifier trips a test. + +describe("CommandIntent.classifyCommand", () => { + describe("read-only commands (must be exempt from the plan gate)", () => { + const readOnly = [ + "ls", + "ls -la", + "ls -la /some/path", + "cat file.txt", + "cat src/index.ts", + "head -n 20 log.txt", + "tail -f log.txt", + "wc -l file.txt", + "pwd", + "which node", + "echo hello world", + "grep -rn pattern src/", + "rg TODO packages/", + "find . -name '*.ts'", + "find src -type f", + "stat file.txt", + "file binary", + "du -sh .", + "df -h", + "env", + "printenv PATH", + "date", + "whoami", + "uname -a", + "ps aux", + "node --version", + "python3 --version", + "git status", + "git log --oneline -20", + "git diff HEAD~1", + "git show HEAD", + "git branch -a", + "git rev-parse HEAD", + "git ls-files", + "git config --get user.name", + "docker ps", + "docker images", + "kubectl get pods", + "npm ls", + "pip list", + "curl -sL https://example.com/api", + "curl https://example.com", + ] + for (const cmd of readOnly) { + test(`read_only: ${cmd}`, () => { + expect(classifyCommand(cmd)).toBe("read_only") + expect(isReadOnlyCommand(cmd)).toBe(true) + }) + } + }) + + describe("chained read-only commands (every segment must be read-only)", () => { + const readOnlyChains = [ + "ls -la && pwd", + "cat a.txt | grep foo", + "git status; git log --oneline -5", + "cd src && ls", // cd is CWD-only, not a file mutation — but see mutating list below + "curl -sL https://example.com/api | head -80", + "grep -rn foo . | wc -l", + ] + for (const cmd of readOnlyChains) { + test(`read_only chain: ${cmd}`, () => { + // NOTE: `cd` is intentionally NOT in the read-only allow-list (it changes shell state), so the + // "cd src && ls" case documents fail-safe behavior below rather than here. + if (cmd.startsWith("cd ")) return + expect(classifyCommand(cmd)).toBe("read_only") + }) + } + }) + + describe("mutating commands (must stay gated)", () => { + const mutating = [ + "rm file.txt", + "rm -rf /some/dir", + "mv a.txt b.txt", + "cp a.txt b.txt", + "mkdir -p foo/bar", + "touch newfile", + "chmod +x script.sh", + "chown user:group file", + "ln -s a b", + "dd if=/dev/zero of=out", + "sed -i 's/a/b/' file.txt", + "tee out.txt", + "kill 1234", + "git commit -m msg", + "git push origin main", + "git checkout -b feature", + "git reset --hard", + "npm install", + "pip install requests", + "docker run image", + "kubectl apply -f manifest.yaml", + "sudo ls", // privilege elevation → never read-only + "apt-get update", + "systemctl restart nginx", + "export FOO=bar", + "source ~/.bashrc", + ] + for (const cmd of mutating) { + test(`mutating: ${cmd}`, () => { + expect(classifyCommand(cmd)).toBe("mutating") + }) + } + }) + + describe("fail-safe: ambiguous / dangerous shapes resolve to mutating", () => { + const failSafe = [ + "", // empty + " ", // whitespace only + "cat file.txt > out.txt", // output redirection + "echo hi >> log.txt", // append redirection + "ls && rm file.txt", // one mutating segment poisons the whole command + "cat a.txt; rm b.txt", + "grep foo . | tee out.txt", // pipe into a writer + "find . -delete", // find with a mutating action + "find . -exec rm {} \\;", // find -exec + "curl -o out.zip https://example.com/f.zip", // curl writing a file + "curl -O https://example.com/f.zip", + "FOO=bar ls", // inline env assignment prefix + "env FOO=bar node app.js", // env running a command + "$(rm file.txt)", // command substitution + "cat `whoami`", // backticks + "ls <(rm x)", // process substitution + "unknowncmd --flag", // unknown command + "eval 'rm x'", + "exec rm x", + ] + for (const cmd of failSafe) { + test(`fail-safe mutating: ${JSON.stringify(cmd)}`, () => { + expect(classifyCommand(cmd)).toBe("mutating") + }) + } + }) + + describe("fd-duplication is not a file write", () => { + test("2>&1 does not count as an output redirection", () => { + expect(classifyCommand("grep foo file.txt 2>&1")).toBe("read_only") + expect(classifyCommand("ls -la 2>&1 | head")).toBe("read_only") + }) + test("but a real > alongside fd-dup is still mutating", () => { + expect(classifyCommand("grep foo file.txt 2>&1 > out.txt")).toBe("mutating") + }) + }) +}) diff --git a/packages/core/test/deepagent/plan-gate-loop.test.ts b/packages/core/test/deepagent/plan-gate-loop.test.ts index 2aedbdea..4930a2ec 100644 --- a/packages/core/test/deepagent/plan-gate-loop.test.ts +++ b/packages/core/test/deepagent/plan-gate-loop.test.ts @@ -13,20 +13,29 @@ import { planGate, HookPolicy } from "../../src/deepagent/hooks" const gate = new HookPolicy().on("before_tool_use", planGate()) -// Reproduce the chokepoint decision exactly (session/tools.ts). -const decideAt = (sessionId: string, toolId: string, mode: string) => { +// Reproduce the chokepoint decision exactly (session/tools.ts), including the U1 anti-deadlock +// payload fields (staleReason, graceRelease) and the shell command classification. +const decideAt = (sessionId: string, toolId: string, mode: string, command?: string) => { const latch = SessionState.planLatch(sessionId) const planStale = latch?.latch === "stale" && !PlanController.shouldEscapeToHuman(latch) return gate.evaluate({ name: "before_tool_use", payload: { planStale, - isMutating: PlanController.isMutatingTool(toolId), + staleReason: latch?.stale_reason ?? null, + graceRelease: latch != null && PlanController.shouldGraceRelease(latch), + isMutating: PlanController.isMutatingTool(toolId, command), lightweight: PlanController.isLightweightMode(mode), }, }) } +// Mirror the tools.ts block/reset bookkeeping so the loop tests advance the runtime grace counter +// exactly as production does: a block records a gate block; a mutating tool that runs resets it. +const applyOutcome = (sessionId: string, decision: string) => { + if (decision === "block") SessionState.recordPlanGateBlock(sessionId) +} + describe("U1 soft-gate loop (chokepoint contract)", () => { beforeEach(() => { SessionState.configure(mkdtempSync(path.join(tmpdir(), "plan-gate-"))) @@ -75,4 +84,65 @@ describe("U1 soft-gate loop (chokepoint contract)", () => { // gate no longer blocks (escape hatch) — the macro-round stop gate routes to needs_human instead expect(decideAt("gate-s3", "edit", "high").decision).toBe("allow") }) + + // ── U1 anti-deadlock: read-only shell commands are never gated ────────────────────────────────── + test("read-only bash (ls/git status/grep) is allowed even while the plan is stale", () => { + SessionState.getOrCreate("gate-ro", "xhigh") + SessionState.markPlanStale("gate-ro", "validation_failed") + // a mutating bash command IS gated… + expect(decideAt("gate-ro", "bash", "xhigh", "rm -rf build").decision).toBe("block") + // …but read-only shell inspections pass (the agent must be able to see to repair the plan). + expect(decideAt("gate-ro", "bash", "xhigh", "ls -la").decision).toBe("allow") + expect(decideAt("gate-ro", "bash", "xhigh", "git status").decision).toBe("allow") + expect(decideAt("gate-ro", "bash", "xhigh", "grep -rn TODO src/").decision).toBe("allow") + expect(decideAt("gate-ro", "bash", "xhigh", "cat file.txt | head -20").decision).toBe("allow") + }) + + // ── U1 anti-deadlock: runtime-driven grace release (the production-deadlock fix) ──────────────── + test("grace release: after repeated blocks with no re-plan, the gate releases WITHOUT model help", () => { + SessionState.getOrCreate("gate-grace", "xhigh") + SessionState.markPlanStale("gate-grace", "validation_failed") + + // The model keeps trying a mutating bash command and NEVER calls the plan tool (the exact + // production failure mode: 280 consecutive blocked bash calls). Each block advances the runtime + // grace counter — no setPlan required. + for (let i = 0; i < PlanController.DEFAULT_GRACE_BLOCK_LIMIT; i++) { + const decision = decideAt("gate-grace", "bash", "xhigh", "make build").decision + expect(decision).toBe("block") + applyOutcome("gate-grace", decision) + } + + // The escape-to-human hatch has NOT fired (replan_count never advanced — the model didn't + // cooperate), proving the old hatch alone would have deadlocked forever here. + expect(PlanController.shouldEscapeToHuman(SessionState.planLatch("gate-grace")!)).toBe(false) + + // But the runtime grace release fires: the next mutating call is downgraded to a WARN (tool runs, + // reminder attached) instead of a hard block. The agent is never permanently denied its tools. + expect(PlanController.shouldGraceRelease(SessionState.planLatch("gate-grace")!)).toBe(true) + expect(decideAt("gate-grace", "bash", "xhigh", "make build").decision).toBe("warn") + }) + + test("grace counter resets when forward progress happens (mutating tool executes)", () => { + SessionState.getOrCreate("gate-reset", "xhigh") + SessionState.markPlanStale("gate-reset", "validation_failed") + SessionState.recordPlanGateBlock("gate-reset") + SessionState.recordPlanGateBlock("gate-reset") + expect(SessionState.planLatch("gate-reset")!.consecutive_blocks).toBe(2) + // a mutating tool actually ran → reset + SessionState.resetPlanGateBlocks("gate-reset") + expect(SessionState.planLatch("gate-reset")!.consecutive_blocks).toBe(0) + expect(PlanController.shouldGraceRelease(SessionState.planLatch("gate-reset")!)).toBe(false) + }) + + // ── U1 anti-deadlock: a new user message must not hard-block work the user is asking for ──────── + test("user_appended stale reason warns (tool runs) rather than hard-blocking, even at xhigh", () => { + SessionState.getOrCreate("gate-user", "xhigh") + SessionState.markPlanStale("gate-user", "user_appended") + // mutating edit is downgraded to warn (runs + reminder), not blocked + expect(decideAt("gate-user", "edit", "xhigh").decision).toBe("warn") + // whereas a genuine reality-change signal still hard-blocks + SessionState.clearPlanStale("gate-user") + SessionState.markPlanStale("gate-user", "validation_failed") + expect(decideAt("gate-user", "edit", "xhigh").decision).toBe("block") + }) }) diff --git a/packages/deepagent-code/src/plugin/openai/codex.ts b/packages/deepagent-code/src/plugin/openai/codex.ts index ebe7d8e2..6e305b4f 100644 --- a/packages/deepagent-code/src/plugin/openai/codex.ts +++ b/packages/deepagent-code/src/plugin/openai/codex.ts @@ -401,11 +401,18 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug }, auth: { provider: "openai", - async loader(getAuth) { + async loader(getAuth, provider) { const auth = await getAuth() - const websocketFetch = options.experimentalWebSockets - ? OpenAIWebSocketPool.createWebSocketFetch({ httpFetch: fetch }) - : undefined + // A custom baseURL means the user is routing OpenAI through a proxy / self-hosted gateway / + // air-gapped mirror (set via SettingsStore, the official-provider override channel). Those + // endpoints speak plain HTTP, not the ChatGPT-backend WebSocket protocol, so the WS transport + // must be skipped even when it is otherwise enabled — otherwise every request would try to + // open a ws:// connection the proxy cannot answer. + const hasCustomBaseURL = typeof provider?.options?.baseURL === "string" && provider.options.baseURL !== "" + const websocketFetch = + options.experimentalWebSockets && !hasCustomBaseURL + ? OpenAIWebSocketPool.createWebSocketFetch({ httpFetch: fetch }) + : undefined if (websocketFetch) { websocketFetches.push(websocketFetch) websocketFetchInstalled = true diff --git a/packages/deepagent-code/src/provider/provider.ts b/packages/deepagent-code/src/provider/provider.ts index 1d869bbc..a3ccc23c 100644 --- a/packages/deepagent-code/src/provider/provider.ts +++ b/packages/deepagent-code/src/provider/provider.ts @@ -1309,6 +1309,19 @@ export const layer = Layer.effect( // via `provider.` in config (credentials from options.apiKey/env). const officialProviderIDs = OFFICIAL_PROVIDER_ID_SET + // Seed the official-provider baseURL override (SettingsStore, the sanctioned channel for + // official transport) onto the CATALOG entry up front — before the plugin auth loaders and + // custom loaders run. This makes the endpoint visible to (a) the OpenAI codex auth loader, so + // it can skip the WebSocket transport when a proxy/gateway baseURL is set (a proxy speaks + // HTTP, not the ChatGPT-backend WS protocol), and (b) resolveSDK, which reads + // `options.baseURL`. The timeout/retry transport keys are merged later (they don't affect the + // loader path); only baseURL needs to be early. + for (const [id, transport] of Object.entries(officialTransport)) { + const entry = database[id] + if (!entry || !transport?.baseURL) continue + entry.options = { ...entry.options, baseURL: transport.baseURL } + } + const providers: Record = {} as Record const languages = new Map() const modelLoaders: { diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/groups/deepagent.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/groups/deepagent.ts index c8ff77c0..891d2776 100644 --- a/packages/deepagent-code/src/server/routes/instance/httpapi/groups/deepagent.ts +++ b/packages/deepagent-code/src/server/routes/instance/httpapi/groups/deepagent.ts @@ -322,6 +322,10 @@ export const DeepAgentGoalSnapshot = Schema.Struct({ }) export const DeepAgentGoalStatusResult = Schema.Struct({ goal: Schema.NullOr(DeepAgentGoalSnapshot) }) export const DeepAgentGoalMutateResult = Schema.Struct({ ok: Schema.Boolean }) +export const DeepAgentGoalStartableResult = Schema.Struct({ + startable: Schema.Boolean, + source: Schema.Literals(["plan", "file", "none"]), +}) // ── V3.9 §B Repo & Wiki ──────────────────────────────────────────────────── // The human-facing projection of the four graphs. Read-only browse + governed knowledge edit + @@ -650,6 +654,13 @@ export const DeepAgentApi = HttpApi.make("deepagent").add( error: DeepAgentPromotionError, }), ) + .add( + HttpApiEndpoint.get("goalStartable", `${root}/goal/startable`, { + query: Schema.Struct({ ...WorkspaceRoutingQueryFields, sessionID: Schema.String }), + success: described(DeepAgentGoalStartableResult, "Whether a goal can be started + plan source"), + error: DeepAgentPromotionError, + }), + ) .add( HttpApiEndpoint.get("wikiPages", `${root}/wiki/pages`, { query: Schema.Struct({ ...WorkspaceRoutingQueryFields, type: Schema.optional(Schema.String) }), diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/deepagent.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/deepagent.ts index fea83b61..015bf57c 100644 --- a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/deepagent.ts +++ b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/deepagent.ts @@ -559,6 +559,9 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen const goalStatus = Effect.fn("DeepAgentHttpApi.goalStatus")(function* (ctx) { return { goal: yield* goals.status(ctx.query.sessionID) } }) + const goalStartable = Effect.fn("DeepAgentHttpApi.goalStartable")(function* (ctx) { + return yield* goals.startable(ctx.query.sessionID) + }) // ── V3.9 §B Repo & Wiki ───────────────────────────────────────────────── // Read-only projection + governed knowledge edit + full-text search. All fail-closed on the wiki @@ -679,6 +682,7 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen .handle("goalResume", goalResume) .handle("goalStop", goalStop) .handle("goalStatus", goalStatus) + .handle("goalStartable", goalStartable) .handle("wikiPages", wikiPages) .handle("wikiPage", wikiPage) .handle("wikiSearch", wikiSearch) diff --git a/packages/deepagent-code/src/session/goal-manager.ts b/packages/deepagent-code/src/session/goal-manager.ts index 3dc6d645..caecb130 100644 --- a/packages/deepagent-code/src/session/goal-manager.ts +++ b/packages/deepagent-code/src/session/goal-manager.ts @@ -72,6 +72,13 @@ type GoalControl = { jobId: string paused: boolean stopped: boolean + // Last-known observable status, cached so pause/resume/stop can publish an IMMEDIATE goal.updated that + // carries the real ledger (not zeros). Updated every tick by publishStatus. Without this, a control + // transition would either wait for the next tick (pause/resume — slow) or never publish at all (stop + // cancels the job, so no further tick fires), leaving the UI status bar stuck on the prior phase. + ledger: { ticks: number; tokens: number; cost: number; wallclockMs: number } + stallCount: number + gaps: readonly string[] } export type StartGoalInput = { @@ -98,12 +105,23 @@ export type GoalSnapshot = { readonly running: boolean } +// Whether a goal can be started for a session RIGHT NOW, and where its plan would come from. The client +// gates the "convert plan → goal" affordance on this instead of guessing from session_plan alone — +// session_plan is only populated by the plan TOOL, but loop/design modes author the plan as the repo +// file `.deepagent-code/plans/goal+plan.md`, which start() also accepts. `source` lets the UI phrase +// the action correctly (existing in-session plan vs the authored repo file). +export type GoalStartable = { + readonly startable: boolean + readonly source: "plan" | "file" | "none" +} + export interface Interface { readonly start: (input: StartGoalInput) => Effect.Effect readonly pause: (sessionID: string) => Effect.Effect readonly resume: (sessionID: string) => Effect.Effect readonly stop: (sessionID: string) => Effect.Effect readonly status: (sessionID: string) => Effect.Effect + readonly startable: (sessionID: string) => Effect.Effect } export class Service extends Context.Service()("@deepagent-code/GoalManager") {} @@ -186,29 +204,86 @@ export const layer = Layer.effect( return m }) - // Publish a status → both the goal.updated event and the session-state active-goal pointer. + // Low-level publisher: emit a goal.updated event over the SSE bridge. Best-effort (ignore) so a + // publish failure never crashes the caller (start route or background driver tick). + const publishGoalEvent = ( + sessionID: string, + payload: { + goalId: string + planDocId: string + phase: string + ledger: { ticks: number; tokens: number; cost: number; wallclockMs: number } + stallCount: number + gaps: readonly string[] + }, + ) => + events + .publish(GoalEvent.Updated, { + sessionID: SessionID.make(sessionID), + goalId: payload.goalId, + planDocId: payload.planDocId, + phase: payload.phase, + ledger: payload.ledger, + stallCount: payload.stallCount, + gaps: [...payload.gaps], + }) + .pipe(Effect.ignore) + + // Publish a driver status → the goal.updated event, the session-state active-goal pointer, AND the + // cached last-known status on the control (so control transitions can publish the real ledger). const publishStatus = (sessionID: string, status: GoalStatus) => Effect.gen(function* () { const phase = status.phase as string AgentGateway.DeepAgentSessionState.setActiveGoalPhase(sessionID, phase as never) - yield* events - .publish(GoalEvent.Updated, { - sessionID: SessionID.make(sessionID), - goalId: status.goalId, - planDocId: status.planDocId, - phase, - ledger: { - ticks: status.ledger.ticks, - tokens: status.ledger.tokens, - cost: status.ledger.cost, - wallclockMs: status.ledger.wallclockMs, - }, - stallCount: status.stallCount, - gaps: status.gaps, - }) - .pipe(Effect.ignore) + const ledger = { + ticks: status.ledger.ticks, + tokens: status.ledger.tokens, + cost: status.ledger.cost, + wallclockMs: status.ledger.wallclockMs, + } + yield* mutateControl(sessionID, (ctrl) => { + ctrl.ledger = ledger + ctrl.stallCount = status.stallCount + ctrl.gaps = status.gaps + }) + yield* publishGoalEvent(sessionID, { + goalId: status.goalId, + planDocId: status.planDocId, + phase, + ledger, + stallCount: status.stallCount, + gaps: status.gaps, + }) + }) + + // Publish an IMMEDIATE goal.updated for a control transition (pause/resume/stop). Reuses the control's + // cached ledger/stall/gaps so the UI keeps its live budget readout while only the phase changes. + const publishControlPhase = (sessionID: string, control: GoalControl, phase: string) => + publishGoalEvent(sessionID, { + goalId: control.goalId, + planDocId: control.planDocId, + phase, + ledger: control.ledger, + stallCount: control.stallCount, + gaps: control.gaps, }) + // Reflect a driver's terminal outcome into the active-goal pointer — but ONLY if the goal is still + // controlled. A user `stop()` clears the control (setControl null) and has already set the pointer to + // "stopped" and published it; the cancelled driver may still settle with its own outcome (e.g. it + // observed shouldStop and returned "needs_human") whose late tap would otherwise clobber "stopped". + // Guarding on a live control makes the explicit stop authoritative and drops the racing outcome. + const finalizeOutcome = (sessionID: string, outcome: string) => + getControl(sessionID).pipe( + Effect.flatMap((c) => + Effect.sync(() => { + if (outcome !== "continue" && c != null) { + AgentGateway.DeepAgentSessionState.setActiveGoalPhase(sessionID, outcome as never) + } + }), + ), + ) + const start: Interface["start"] = (input) => Effect.gen(function* () { const sessionID = input.sessionID @@ -299,6 +374,20 @@ export const layer = Layer.effect( startedAt: new Date().toISOString(), }) + // Emit an IMMEDIATE goal.updated (phase=running, empty ledger) BEFORE the first tick. The first + // driver tick is a full subagent turn (tens of seconds), and onStatus only fires AFTER it — so + // without this, the client's session_goal store stays empty and the "convert plan → goal" hint + // never flips to the GoalStatusBar, leaving the user with no confirmation the goal started. This + // seeds the store the moment start returns, so the UI reflects the running goal instantly. + yield* publishGoalEvent(sessionID, { + goalId: handle.goalId, + planDocId: handle.planDocId, + phase: "running", + ledger: { ticks: 0, tokens: 0, cost: 0, wallclockMs: 0 }, + stallCount: 0, + gaps: [], + }) + // The driver ports read the per-session control flags (pause/stop) live. const ports: GoalDriverPorts = { onStatus: (status) => publishStatus(sessionID, status), @@ -312,15 +401,9 @@ export const layer = Layer.effect( title: `goal ${handle.goalId}`, metadata: { sessionID, goalId: handle.goalId }, run: GoalDriver.runToCompletion({ deps, handle, ports }).pipe( - Effect.tap((outcome) => - Effect.sync(() => { - // A terminal outcome clears the running pointer to its terminal phase; a paused exit - // leaves the pointer for a later resume. - if (outcome !== "continue") { - AgentGateway.DeepAgentSessionState.setActiveGoalPhase(sessionID, outcome as never) - } - }), - ), + // A terminal outcome clears the running pointer to its terminal phase (unless the user already + // stopped it); a paused exit ("continue") leaves the pointer for a later resume. + Effect.tap((outcome) => finalizeOutcome(sessionID, outcome)), Effect.map((outcome) => `goal ${handle.goalId}: ${outcome}`), Effect.catchCause(() => Effect.succeed(`goal ${handle.goalId}: driver defect`)), ), @@ -332,6 +415,9 @@ export const layer = Layer.effect( jobId: job.id, paused: false, stopped: false, + ledger: { ticks: 0, tokens: 0, cost: 0, wallclockMs: 0 }, + stallCount: 0, + gaps: [], }) return { goalId: handle.goalId, planDocId: handle.planDocId, phase: "running", running: true } @@ -343,6 +429,8 @@ export const layer = Layer.effect( if (!c) return false yield* mutateControl(sessionID, (ctrl) => (ctrl.paused = true)) AgentGateway.DeepAgentSessionState.setActiveGoalPhase(sessionID, "paused") + // Immediate goal.updated so the status bar flips to "paused" now, not after the in-flight tick. + yield* publishControlPhase(sessionID, c, "paused") return true }) @@ -388,17 +476,15 @@ export const layer = Layer.effect( title: `goal ${c.goalId} (resumed)`, metadata: { sessionID, goalId: c.goalId }, run: GoalDriver.runToCompletion({ deps, handle, ports }).pipe( - Effect.tap((outcome) => - Effect.sync(() => { - if (outcome !== "continue") - AgentGateway.DeepAgentSessionState.setActiveGoalPhase(sessionID, outcome as never) - }), - ), + Effect.tap((outcome) => finalizeOutcome(sessionID, outcome)), Effect.map((outcome) => `goal ${c.goalId}: ${outcome}`), Effect.catchCause(() => Effect.succeed(`goal ${c.goalId}: driver defect`)), ), }) yield* mutateControl(sessionID, (ctrl) => (ctrl.jobId = job.id)) + // Immediate goal.updated so the status bar flips back to "running" now. The resumed driver's + // first tick may be tens of seconds away; without this the bar would stay stuck on "paused". + yield* publishControlPhase(sessionID, c, "running") return true }) @@ -410,6 +496,10 @@ export const layer = Layer.effect( yield* background.cancel(c.jobId).pipe(Effect.ignore) AgentGateway.DeepAgentSessionState.setActiveGoalPhase(sessionID, "stopped") yield* setControl(sessionID, null) + // Immediate goal.updated is MANDATORY here: cancelling the job means no further tick will ever + // fire onStatus, so this is the ONLY event that can move the status bar off its prior phase. + // Without it the UI is stuck showing "running" forever after the user hits stop. + yield* publishControlPhase(sessionID, c, "stopped") return true }) @@ -426,11 +516,23 @@ export const layer = Layer.effect( } }) - // Reference `flags` so an unused-var lint stays quiet; the real gate is makeGoalLoopWiring returning - // null when experimentalGoalLoop is off (checked in start/resume). - void flags + // Whether start() would find a plan to run — mirrors its plan-resolution precedence WITHOUT side + // effects (no doc materialized, no driver started). Flag-gated: a disabled goal loop is never + // startable. session_plan (loop-tool authored) wins; else the repo goal+plan.md (loop/design file); + // else none. Used by the client to gate the convert-to-goal affordance in the modes where it applies. + const startable: Interface["startable"] = (sessionID) => + Effect.gen(function* () { + if (!flags.experimentalGoalLoop) return { startable: false, source: "none" as const } + const existing = AgentGateway.DeepAgentSessionState.getPlan(sessionID) as PlanDoc | null + if (existing != null) return { startable: true, source: "plan" as const } + const session = yield* sessions.get(SessionID.make(sessionID)).pipe(Effect.orDie) + const cwd = session.directory ?? process.cwd() + const fromFile = readGoalPlanFile(cwd, sessionID) + if (fromFile?.plan != null) return { startable: true, source: "file" as const } + return { startable: false, source: "none" as const } + }) - return Service.of({ start, pause, resume, stop, status }) + return Service.of({ start, pause, resume, stop, status, startable }) }), ) diff --git a/packages/deepagent-code/src/session/tools.ts b/packages/deepagent-code/src/session/tools.ts index 14176cc1..64119b6f 100644 --- a/packages/deepagent-code/src/session/tools.ts +++ b/packages/deepagent-code/src/session/tools.ts @@ -152,11 +152,26 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { // plan step. high warns + auto-replans; xhigh/max/ultra hard-block. const hardGate = !lightweight && AgentGateway.DeepAgentPlanController.hardGateEnabled(agentMode) const plan = AgentGateway.DeepAgentSessionState.getPlan(ctx.sessionID) + // Read-only shell commands (ls/cat/grep/git status/curl probe/…) are the agent's eyes and + // must never be gated — pass the command string so the classifier can exempt them. Any + // ambiguity is classified mutating (fail-safe). Non-shell tools ignore the command arg. + const command = + (item.id === "bash" || item.id === "shell") && + typeof (args as { command?: unknown } | undefined)?.command === "string" + ? ((args as { command: string }).command) + : null + const isMutating = AgentGateway.DeepAgentPlanController.isMutatingTool(item.id, command) + // U1 anti-deadlock: runtime-driven grace release. When the gate has already blocked this + // stale plan too many times with no forward progress, release (with a strong reminder) + // rather than deadlock a model that will not repair the plan. + const graceRelease = latch != null && AgentGateway.DeepAgentPlanController.shouldGraceRelease(latch) const gateDecision = PlanHook.evaluate({ name: "before_tool_use", payload: { planStale, - isMutating: AgentGateway.DeepAgentPlanController.isMutatingTool(item.id), + staleReason: latch?.stale_reason ?? null, + graceRelease, + isMutating, lightweight, hardGate, hasActiveStep: AgentGateway.DeepAgentPlanController.hasActiveStep(plan), @@ -164,21 +179,36 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }, }) if (gateDecision.decision === "block") { + // U1 anti-deadlock: count this block on the runtime counter so shouldGraceRelease can + // eventually fire even if the model never calls the plan tool. + AgentGateway.DeepAgentSessionState.recordPlanGateBlock(ctx.sessionID) const reason = latch?.stale_reason != null ? `The plan is stale (${latch.stale_reason}). ${gateDecision.blockReason}. Call the \`plan\` tool to update your plan, then retry this edit.` : `${gateDecision.blockReason}. Call the \`plan\` tool first.` return { title: "Plan update required", output: reason, metadata: {} } } + // A gate WARN (grace release, user_appended, or a high-mode hard-gate binding miss) lets + // the tool run but must surface the reminder to the model. Suppressed for lightweight + // modes (general/direct), which are intentionally never slowed by the plan machinery. + const gateWarnReason = + gateDecision.decision === "warn" && !lightweight ? gateDecision.blockReason : undefined const result = yield* item.execute(args, ctx) // U10: count a successful mutating tool call toward the progress-nudge budget. Only - // mutating tools (edit/write/patch/shell) count; the counter resets when the model next - // changes a plan step's status. No-op when there is no plan. - if (AgentGateway.DeepAgentPlanController.isMutatingTool(item.id)) { + // mutating tools (edit/write/patch/shell-mutation) count; the counter resets when the model + // next changes a plan step's status. No-op when there is no plan. + if (isMutating) { AgentGateway.DeepAgentSessionState.recordMutation(ctx.sessionID) + // U1 anti-deadlock: a mutating tool actually executed → forward progress happened, so + // reset the consecutive-block counter (block cadence restarts from zero). + AgentGateway.DeepAgentSessionState.resetPlanGateBlocks(ctx.sessionID) } + const withReminder = + gateWarnReason && typeof result.output === "string" + ? { ...result, output: `⚠️ Plan gate: ${gateWarnReason}.\n\n${result.output}` } + : result const output = { - ...result, + ...withReminder, attachments: result.attachments?.map((attachment) => ({ ...attachment, id: PartID.ascending(), diff --git a/packages/deepagent-code/src/settings/store.ts b/packages/deepagent-code/src/settings/store.ts index 79e9aa7e..f972c121 100644 --- a/packages/deepagent-code/src/settings/store.ts +++ b/packages/deepagent-code/src/settings/store.ts @@ -43,8 +43,14 @@ export namespace SettingsStore { } /** Transport tuning for a single official provider. Mirrors the transport keys the provider - * loader strips from `options` and applies as fetch-level abort controllers. */ + * loader strips from `options` and applies as fetch-level abort controllers, PLUS an optional + * `baseURL` endpoint override. Official providers deliberately ignore `config.provider.`, so a + * corporate proxy / self-hosted gateway / air-gapped mirror in front of an official provider can + * ONLY be pointed to here (the connect dialog's advanced section) — never via the config file. + * When `baseURL` is set, the OpenAI codex WebSocket transport is skipped (a proxy speaks HTTP, not + * the ChatGPT-backend WS protocol); see plugin/openai/codex.ts. */ export interface TransportSettings { + baseURL?: string headerTimeout?: number | false chunkTimeout?: number timeout?: number | false @@ -112,9 +118,25 @@ export namespace SettingsStore { return Object.keys(out).length > 0 ? out : undefined } + // Accept only a well-formed absolute http(s) endpoint. A malformed or non-http value is dropped + // (not persisted) so a hand-edited settings file can never route an official provider somewhere + // unexpected — the provider then falls back to its fixed catalog endpoint. + const httpUrl = (v: unknown): string | undefined => { + const s = str(v) + if (!s) return undefined + try { + const u = new URL(s) + return u.protocol === "http:" || u.protocol === "https:" ? s : undefined + } catch { + return undefined + } + } + function normalizeTransport(input: unknown): TransportSettings | undefined { if (!isRecord(input)) return undefined const out: TransportSettings = {} + const bu = httpUrl(input.baseURL) + if (bu !== undefined) out.baseURL = bu const ht = timeout(input.headerTimeout) if (ht !== undefined) out.headerTimeout = ht const ct = posInt(input.chunkTimeout) diff --git a/packages/deepagent-code/test/lib/seed-ripgrep.ts b/packages/deepagent-code/test/lib/seed-ripgrep.ts new file mode 100644 index 00000000..45bd335a --- /dev/null +++ b/packages/deepagent-code/test/lib/seed-ripgrep.ts @@ -0,0 +1,56 @@ +import os from "os" +import path from "path" +import fs from "fs/promises" + +// Seed the ripgrep binary into a test home's cache so search-backed tools (grep/glob/shell/skill, +// and the tool-registry construction that eagerly resolves `rg`) never trigger the ~50s GitHub +// download that otherwise blows past every per-test timeout and makes those tests appear to hang. +// +// The tool resolves `rg` from `/.deepagent/code/cache/bin` (see core global-path.ts: +// resolveDataPath → DEEPAGENT_CODE_TEST_HOME/.deepagent/code, then Global.Path.bin = /cache/bin). +// Any test that repoints DEEPAGENT_CODE_TEST_HOME to a fresh dir must re-seed, or its rg resolution +// falls through to the network download. This helper is idempotent and best-effort: if no local `rg` +// is available (clean CI image with no ripgrep anywhere), it does nothing and the tool keeps its own +// download fallback — behavior is unchanged, just not pre-seeded. + +let cachedSource: string | null | undefined + +const rgName = () => (process.platform === "win32" ? "rg.exe" : "rg") + +const isFile = (p: string) => + fs + .stat(p) + .then((s) => s.isFile()) + .catch(() => false) + +// Locate a reusable `rg` binary: first a real one on PATH (shell-function shims are not files and are +// skipped), then the developer's real-home cache where a prior non-test run already downloaded it. +const findLocalRg = async (): Promise => { + if (cachedSource !== undefined) return cachedSource + const name = rgName() + for (const base of (process.env.PATH ?? "").split(path.delimiter)) { + if (!base) continue + const candidate = path.join(base, name) + if (await isFile(candidate)) return (cachedSource = candidate) + } + const realHomeRg = path.join(os.homedir(), ".deepagent", "code", "cache", "bin", name) + if (await isFile(realHomeRg)) return (cachedSource = realHomeRg) + return (cachedSource = null) +} + +/** + * Copy a local `rg` into `/.deepagent/code/cache/bin`. Idempotent (no-op if already seeded), + * best-effort (no throw). Call from preload for the default test home, and from any test that + * repoints DEEPAGENT_CODE_TEST_HOME to a fresh directory. + */ +export const seedRipgrep = async (homeDir: string): Promise => { + const name = rgName() + const binDir = path.join(homeDir, ".deepagent", "code", "cache", "bin") + const target = path.join(binDir, name) + if (await isFile(target)) return + const source = await findLocalRg() + if (!source) return + await fs.mkdir(binDir, { recursive: true }).catch(() => undefined) + await fs.copyFile(source, target).catch(() => undefined) + if (process.platform !== "win32") await fs.chmod(target, 0o755).catch(() => undefined) +} diff --git a/packages/deepagent-code/test/plugin/codex.test.ts b/packages/deepagent-code/test/plugin/codex.test.ts index f7097539..1c85bc00 100644 --- a/packages/deepagent-code/test/plugin/codex.test.ts +++ b/packages/deepagent-code/test/plugin/codex.test.ts @@ -140,6 +140,26 @@ describe("plugin.codex", () => { await enabled.dispose?.() }) + test("skips the websocket transport when a custom baseURL is set (proxy/gateway routing)", async () => { + // Even with WS enabled, a custom baseURL means OpenAI is routed through a proxy / self-hosted + // gateway that speaks plain HTTP — the ChatGPT-backend WS protocol would not work there, so the + // loader must fall back to HTTP and not install a ws fetch. + const enabled = await CodexAuthPlugin({} as never, { experimentalWebSockets: true }) + + const withBaseURL = await enabled.auth!.loader!( + async () => ({ type: "api", key: "sk-test" }) as never, + { options: { baseURL: "https://proxy.corp.internal/openai/v1" } } as never, + ) + const withoutBaseURL = await enabled.auth!.loader!( + async () => ({ type: "api", key: "sk-test" }) as never, + { options: {} } as never, + ) + + expect(withBaseURL.fetch).toBeUndefined() + expect(withoutBaseURL.fetch).toBeFunction() + await enabled.dispose?.() + }) + test("deduplicates concurrent Codex token refreshes", async () => { let auth = { type: "oauth" as const, diff --git a/packages/deepagent-code/test/preload.ts b/packages/deepagent-code/test/preload.ts index 6c1177a2..4e714c09 100644 --- a/packages/deepagent-code/test/preload.ts +++ b/packages/deepagent-code/test/preload.ts @@ -68,6 +68,15 @@ const testHome = path.join(dir, "home") await fs.mkdir(testHome, { recursive: true }) process.env["DEEPAGENT_CODE_TEST_HOME"] = testHome +// Seed the ripgrep binary into the isolated test cache. The grep/glob/shell/skill tools (and the +// tool-registry construction that eagerly resolves `rg`) look it up under +// `/.deepagent/code/cache/bin` and, when absent, download it from GitHub releases — a ~50s +// transfer that blows past every per-test timeout and makes search-backed tests appear to hang. +// Seeding it up front makes those tests deterministic and offline. Tests that repoint +// DEEPAGENT_CODE_TEST_HOME to a fresh dir re-seed via the same helper. +const { seedRipgrep } = await import("./lib/seed-ripgrep") +await seedRipgrep(testHome) + // Set test managed config directory to isolate tests from system managed settings const testManagedConfigDir = path.join(dir, "managed") process.env["DEEPAGENT_CODE_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir diff --git a/packages/deepagent-code/test/provider/provider.test.ts b/packages/deepagent-code/test/provider/provider.test.ts index 9a7356bf..fa946b3c 100644 --- a/packages/deepagent-code/test/provider/provider.test.ts +++ b/packages/deepagent-code/test/provider/provider.test.ts @@ -11,6 +11,7 @@ import { disposeAllInstances, provideInstanceEffect, tmpdirScoped, TestInstance import { markPluginDependenciesReady } from "../fixture/plugin" import { Auth } from "@/auth" import { Config } from "@/config/config" +import { SettingsStore } from "@/settings/store" import { Env } from "../../src/env" import { Plugin } from "../../src/plugin/index" import { Provider } from "@/provider/provider" @@ -60,6 +61,10 @@ afterEach(async () => { else process.env[key] = value } originalEnv.clear() + // Reset any SettingsStore official-transport override written by a test so it can't leak into the + // next (the store is backed by a shared global file + in-memory cache). + await SettingsStore.update({ providers: { openai: {}, anthropic: {} } }).catch(() => {}) + SettingsStore.invalidate() await disposeAllInstances() }) @@ -211,6 +216,26 @@ it.instance( }, ) +it.instance( + "official provider baseURL override comes from SettingsStore (the sanctioned channel)", + Effect.gen(function* () { + // Config baseURL is ignored for official providers (proven above). The ONE supported way to point + // an official provider at a proxy / self-hosted gateway / air-gapped mirror is SettingsStore + // (the connect dialog's advanced section). Written before the provider state materializes, it must + // land on the resolved provider's options so resolveSDK routes there. + yield* Effect.promise(() => + SettingsStore.update({ providers: { openai: { baseURL: "https://proxy.corp.internal/openai/v1" } } }), + ) + SettingsStore.invalidate() + yield* connect(ProviderV2.ID.openai, "test-openai-key") + const providers = yield* list + const provider = providers[ProviderV2.ID.openai] + expect(provider).toBeDefined() + expect(provider.source).toBe("api") + expect(provider.options.baseURL).toBe("https://proxy.corp.internal/openai/v1") + }), +) + it.instance( "non-official catalog id is a third-party provider from config", Effect.gen(function* () { diff --git a/packages/deepagent-code/test/session/llm.test.ts b/packages/deepagent-code/test/session/llm.test.ts index 86cc8c87..a66779b5 100644 --- a/packages/deepagent-code/test/session/llm.test.ts +++ b/packages/deepagent-code/test/session/llm.test.ts @@ -14,6 +14,7 @@ import { Auth } from "@/auth" import { Config } from "@/config/config" import { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" +import { SettingsStore } from "@/settings/store" import { ModelsDev } from "@deepagent-code/core/models-dev" import { Plugin } from "@/plugin" @@ -30,29 +31,29 @@ import { ModelV2 } from "@deepagent-code/core/model" type ConfigModel = NonNullable[string]["models"]>[string] -const openAIConfig = (model: ModelsDev.Provider["models"][string], baseURL: string): Partial => { - const { experimental: _experimental, ...configModel } = model - return { - enabled_providers: ["openai"], - provider: { - openai: { - name: "OpenAI", - env: ["OPENAI_API_KEY"], - npm: "@ai-sdk/openai", - api: "https://api.openai.com/v1", - models: { - [model.id]: JSON.parse(JSON.stringify(configModel)) as ConfigModel, - }, - options: { - apiKey: "test-openai-key", - baseURL, - }, - }, - }, - } -} -const it = testEffect(Layer.mergeAll(LLM.defaultLayer, Provider.defaultLayer)) +const it = testEffect(Layer.mergeAll(LLM.defaultLayer, Provider.defaultLayer, Auth.defaultLayer)) + +// Register an OFFICIAL provider (openai/anthropic/google/…) for a test and resolve one of its models +// pointed at the mock server, using the SAME channels a real deployment uses. Post-"官方 provider +// 收窄+第三方隔离" (6065b22), official providers no longer read `config.provider.`: they register +// ONLY from the auth key store, and their endpoint can be overridden ONLY via SettingsStore (the +// connect dialog's advanced section — e.g. a corporate proxy / self-hosted gateway). So we: +// 1. seed the auth key store → the provider registers, +// 2. write the SettingsStore baseURL override → resolveSDK routes to the mock (and the OpenAI codex +// WS transport is skipped because a custom baseURL is present). +// Both writes happen before the first `getModel`, i.e. before the provider InstanceState +// materializes, so they are picked up. SettingsStore is global-file backed and cached, so invalidate +// to be safe against a prior test's read. Cleanup (auth/settings reset) is handled by the fresh +// per-test instance home (withTmpdirInstance) + the afterEach below. +const resolveOfficialModelAt = (providerID: string, modelID: string, baseURL: string) => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set(providerID, { type: "api", key: `test-${providerID}-key` }) + SettingsStore.invalidate() + yield* Effect.promise(() => SettingsStore.update({ providers: { [providerID]: { baseURL } } })) + return yield* Provider.use.getModel(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)) + }) // LLM.stream returns a Stream, not an Effect, so we can't use the serviceUse proxy. const drain = (input: LLM.StreamInput) => LLM.Service.use((svc) => svc.stream(input).pipe(Stream.runDrain)) @@ -663,6 +664,9 @@ beforeAll(() => { beforeEach(() => { state.queue.length = 0 + // SettingsStore is backed by a shared global file + in-memory cache; drop the cache so a baseURL + // override written by resolveOfficialModelAt in one test never leaks into the next. + SettingsStore.invalidate() }) afterAll(() => { @@ -1014,7 +1018,7 @@ describe("session.llm.stream", () => { ] const request = waitRequest("/responses", createEventResponse(responseChunks, true)) - const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id)) + const resolved = yield* resolveOfficialModelAt("openai", model.id, `${state.server!.url.origin}/v1`) const sessionID = SessionID.make("session-test-2") const agent = { name: "test", @@ -1054,7 +1058,7 @@ describe("session.llm.stream", () => { const maxTokens = body.max_output_tokens as number | undefined expect(maxTokens).toBe(undefined) // match codex cli behavior }), - { config: () => openAIConfig(loadFixture("openai", "gpt-5.2").model, `${state.server!.url.origin}/v1`) }, + { config: () => ({ enabled_providers: ["openai"] }) }, ) it.instance( @@ -1119,7 +1123,7 @@ describe("session.llm.stream", () => { }), ) - const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id)) + const resolved = yield* resolveOfficialModelAt("openai", model.id, `${state.server!.url.origin}/v1`) const sessionID = SessionID.make("session-test-native-flag-off") const agent = { name: "test", @@ -1159,7 +1163,7 @@ describe("session.llm.stream", () => { expect(capture.url.pathname.endsWith("/responses")).toBe(true) expect(capture.body.model).toBe(resolved.api.id) }), - { config: () => openAIConfig(loadFixture("openai", "gpt-5.2").model, `${state.server!.url.origin}/v1`) }, + { config: () => ({ enabled_providers: ["openai"] }) }, ) it.instance( @@ -1189,7 +1193,7 @@ describe("session.llm.stream", () => { ] const request = waitRequest("/responses", createEventResponse(chunks, true)) - const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id)) + const resolved = yield* resolveOfficialModelAt("openai", model.id, `${state.server!.url.origin}/v1`) const sessionID = SessionID.make("session-test-native") const agent = { name: "test", @@ -1226,7 +1230,7 @@ describe("session.llm.stream", () => { expect(JSON.stringify(capture.body.input)).toContain("You are a helpful assistant.") expect(capture.body.input).toContainEqual({ role: "user", content: [{ type: "input_text", text: "Hello" }] }) }), - { config: () => openAIConfig(loadFixture("openai", "gpt-5.2").model, `${state.server!.url.origin}/v1`) }, + { config: () => ({ enabled_providers: ["openai"] }) }, ) it.instance( @@ -1273,7 +1277,7 @@ describe("session.llm.stream", () => { }), ) - const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id)) + const resolved = yield* resolveOfficialModelAt("openai", model.id, "https://injected-openai.test/v1") const sessionID = SessionID.make("session-test-native-injected-tool") const agent = { name: "test", @@ -1325,7 +1329,7 @@ describe("session.llm.stream", () => { ]) expect(executed).toEqual({ args: { query: "weather" }, toolCallId: "call-injected-tool" }) }), - { config: () => openAIConfig(loadFixture("openai", "gpt-5.2").model, "https://injected-openai.test/v1") }, + { config: () => ({ enabled_providers: ["openai"] }) }, ) it.instance( @@ -1361,7 +1365,7 @@ describe("session.llm.stream", () => { const request = waitRequest("/responses", createEventResponse(chunks, true)) let executed: unknown - const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id)) + const resolved = yield* resolveOfficialModelAt("openai", model.id, `${state.server!.url.origin}/v1`) const sessionID = SessionID.make("session-test-native-tool") const agent = { name: "test", @@ -1414,22 +1418,7 @@ describe("session.llm.stream", () => { expect(executed).toEqual({ args: { query: "weather" }, toolCallId: "call-native-tool" }) }), { - config: () => { - const model = loadFixture("openai", "gpt-5.2").model - return { - enabled_providers: ["openai"], - provider: { - openai: { - name: "OpenAI", - env: ["OPENAI_API_KEY"], - npm: "@ai-sdk/openai", - api: "https://api.openai.com/v1", - models: { [model.id]: JSON.parse(JSON.stringify(model)) as ConfigModel }, - options: { apiKey: "test-openai-key", baseURL: `${state.server!.url.origin}/v1` }, - }, - }, - } - }, + config: () => ({ enabled_providers: ["openai"] }), }, ) @@ -1487,7 +1476,7 @@ describe("session.llm.stream", () => { ), ).toString("base64")}` - const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id)) + const resolved = yield* resolveOfficialModelAt("openai", model.id, `${state.server!.url.origin}/v1`) const sessionID = SessionID.make("session-test-data-url") const agent = { name: "test", @@ -1526,7 +1515,7 @@ describe("session.llm.stream", () => { const capture = yield* Effect.promise(() => request) expect(capture.url.pathname.endsWith("/responses")).toBe(true) }), - { config: () => openAIConfig(loadFixture("openai", "gpt-5.2").model, `${state.server!.url.origin}/v1`) }, + { config: () => ({ enabled_providers: ["openai"] }) }, ) const minimaxFixture = { providerID: "minimax", modelID: "MiniMax-M2.5" } @@ -1671,7 +1660,11 @@ describe("session.llm.stream", () => { ] const request = waitRequest("/messages", createEventResponse(chunks)) - const resolved = yield* Provider.use.getModel(ProviderV2.ID.make("anthropic"), ModelV2.ID.make(model.id)) + const resolved = yield* resolveOfficialModelAt( + "anthropic", + model.id, + `${state.server!.url.origin}/v1`, + ) const sessionID = SessionID.make("session-test-anthropic-tools") const agent = { name: "test", @@ -1686,6 +1679,11 @@ describe("session.llm.stream", () => { time: { created: Date.now() }, agent: agent.name, model: { providerID: ProviderV2.ID.make("anthropic"), modelID: resolved.id, variant: "max" }, + // Pin the plain (non-DeepAgent) mode: this test asserts the exact provider request payload + // (message ordering, tool_use/tool_result adjacency), so the DeepAgent orchestrator's + // volatile round-context message must not be injected. The gateway is active by default in + // tests (agentMode undefined ≠ "general"), so pin general to exercise the raw provider path. + metadata: { deepagent: { agent_mode_override: "general" } }, } satisfies SessionV1.User const input = [ @@ -1834,24 +1832,7 @@ describe("session.llm.stream", () => { ], }) }), - { - config: () => { - const model = loadFixture("anthropic", "claude-opus-4-6").model - return { - enabled_providers: ["anthropic"], - provider: { - anthropic: { - name: "Anthropic", - env: ["ANTHROPIC_API_KEY"], - npm: "@ai-sdk/anthropic", - api: "https://api.anthropic.com/v1", - models: { [model.id]: configModel(model) as ConfigModel }, - options: { apiKey: "test-anthropic-key", baseURL: `${state.server!.url.origin}/v1` }, - }, - }, - } - }, - }, + { config: () => ({ enabled_providers: ["anthropic"] }) }, ) const geminiFixture = { providerID: "google", modelID: "gemini-2.5-flash" } @@ -1870,9 +1851,10 @@ describe("session.llm.stream", () => { ] const request = waitRequest(pathSuffix, createEventResponse(chunks)) - const resolved = yield* Provider.use.getModel( - ProviderV2.ID.make(geminiFixture.providerID), - ModelV2.ID.make(model.id), + const resolved = yield* resolveOfficialModelAt( + geminiFixture.providerID, + model.id, + `${state.server!.url.origin}/v1beta`, ) const sessionID = SessionID.make("session-test-4") const agent = { @@ -1891,6 +1873,9 @@ describe("session.llm.stream", () => { time: { created: Date.now() }, agent: agent.name, model: { providerID: ProviderV2.ID.make(geminiFixture.providerID), modelID: resolved.id }, + // Plain provider path (see the anthropic test above): this asserts the exact Gemini request + // body, so the DeepAgent round-context message must not be injected. + metadata: { deepagent: { agent_mode_override: "general" } }, } satisfies SessionV1.User yield* drain({ @@ -1918,15 +1903,6 @@ describe("session.llm.stream", () => { expect(config?.topP).toBe(0.8) expect(config?.maxOutputTokens).toBe(ProviderTransform.maxOutputTokens(resolved)) }), - { - config: () => ({ - enabled_providers: [geminiFixture.providerID], - provider: { - [geminiFixture.providerID]: { - options: { apiKey: "test-google-key", baseURL: `${state.server!.url.origin}/v1beta` }, - }, - }, - }), - }, + { config: () => ({ enabled_providers: [geminiFixture.providerID] }) }, ) }) diff --git a/packages/deepagent-code/test/settings/store.test.ts b/packages/deepagent-code/test/settings/store.test.ts index 0118080f..6822df43 100644 --- a/packages/deepagent-code/test/settings/store.test.ts +++ b/packages/deepagent-code/test/settings/store.test.ts @@ -157,6 +157,33 @@ describe("SettingsStore", () => { expect(cleared.settings.providers).toBeUndefined() }) + test("baseURL override for an official provider round-trips (proxy/gateway routing)", async () => { + const result = await SettingsStore.update({ + providers: { openai: { baseURL: "https://proxy.corp.internal/openai/v1", maxRetries: 5 } }, + }) + expect(result.settings.providers).toEqual({ + openai: { baseURL: "https://proxy.corp.internal/openai/v1", maxRetries: 5 }, + }) + }) + + test("http(s) baseURL accepted; malformed / non-http endpoints dropped", async () => { + const ok = await SettingsStore.update({ providers: { openai: { baseURL: "http://localhost:8080/v1" } } }) + expect(ok.settings.providers).toEqual({ openai: { baseURL: "http://localhost:8080/v1" } }) + + await SettingsStore.update({ providers: { openai: { baseURL: undefined as never } } }) + SettingsStore.invalidate() + // file:// (non-http), a bare non-URL string, and empty string are all rejected so a hand-edited + // settings file can never route an official provider somewhere unexpected. + const bad = await SettingsStore.update({ + providers: { + openai: { baseURL: "file:///etc/passwd" as never }, + anthropic: { baseURL: "not a url" as never }, + google: { baseURL: "" as never }, + }, + }) + expect(bad.settings.providers).toBeUndefined() + }) + test("ignores unknown/garbage on read", async () => { await fs.writeFile( settingsFile(), diff --git a/packages/deepagent-code/test/tool/skill.test.ts b/packages/deepagent-code/test/tool/skill.test.ts index bba2f3f8..c121d303 100644 --- a/packages/deepagent-code/test/tool/skill.test.ts +++ b/packages/deepagent-code/test/tool/skill.test.ts @@ -11,6 +11,7 @@ import { ToolRegistry } from "@/tool/registry" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" import { testEffect } from "../lib/effect" +import { seedRipgrep } from "../lib/seed-ripgrep" const baseCtx: Omit = { sessionID: SessionID.make("ses_test"), @@ -58,6 +59,9 @@ Use this skill. process.env.DEEPAGENT_CODE_TEST_HOME = home }), ) + // Re-seed rg into the repointed test home so tool-registry construction (which resolves rg) + // does not fall through to the ~50s GitHub download and time out. + yield* Effect.promise(() => seedRipgrep(dir)) const registry = yield* ToolRegistry.Service const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } @@ -104,6 +108,9 @@ Use this skill. process.env.DEEPAGENT_CODE_TEST_HOME = home }), ) + // Re-seed rg into the repointed test home so tool-registry construction (which resolves rg) + // does not fall through to the ~50s GitHub download and time out. + yield* Effect.promise(() => seedRipgrep(dir)) const registry = yield* ToolRegistry.Service const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }