diff --git a/.gitignore b/.gitignore
index 2a77479a..b3452621 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,10 @@ tmp
dist
ts-dist
.turbo
+# Web/edge build artifacts (nitro .output, cloudflare .wrangler) — e.g. packages/console, packages/stats
+.output
+.wrangler
+packages/console/app/public/sitemap.xml
**/.serena
.serena/
**/.omo
diff --git a/README.md b/README.md
index 616ccd9c..e211230d 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
-AI coding agent with persistent memory, autonomous goals, and a control plane
+The AI coding agent that remembers, plans, collaborates, and finishes
English |
@@ -14,95 +14,94 @@
Enterprise
-Desktop v1.3
+Desktop 1.4.1 · DeepAgent Core V4.1
---
-DeepAgent Code is an AI coding agent built on persistent document memory. It keeps [opencode](https://github.com/sst/opencode)'s runtime foundations and adds a control plane so the agent behaves less like a one-shot chat and more like a teammate that remembers your project, sharpens vague asks, and goes deep on hard problems.
+DeepAgent Code is an AI coding workspace for work that lasts longer than one prompt. It combines a production coding-agent runtime with durable sessions, connected project memory, live planning, code intelligence, multi-agent collaboration, and human oversight.
-The features below start from a real need — something a plain coding agent, opencode included, leaves on the table — and work down to the architecture we built to serve it.
+You can ask for a small edit, guide a running task without interrupting it, hand over a migration with objective completion criteria, or bring several specialist agents into a decision. DeepAgent keeps the work coherent across turns, restarts, tools, people, and projects.
-## What You Can Do
+## One Workspace, Three Ways to Work
-### Bring your history over — switch tools without starting from zero
+Choose the collaboration style that fits the task:
-**The need:** You've built up months of context in another agent — Codex or Claude Code — and switching tools normally means abandoning all of it: the conversations, the accumulated memory, the skills you tuned. That cost alone keeps people on tools they've outgrown.
+| Mode | You provide | DeepAgent does |
+|---|---|---|
+| **Auto** | A request | Defines the objective, designs and plans as needed, then executes end to end |
+| **Loop** | A goal | Writes an editable `goal+plan.md` and advances it through plan, execute, verify, and iterate ticks |
+| **Design** | Your `goal+plan.md` | Executes your design faithfully without redefining its objective or completion criteria |
-**What DeepAgent does:** One-click import of your existing history. Point it at a Codex or Claude Code installation and it hot-imports your chat sessions, memory, and skills straight into DeepAgent — reading each tool's on-disk format, normalizing it, and replaying it into the document graph so imported conversations behave like native ones. Secrets are redacted on the way in, imported projects stay isolated so nothing collides with your active work, and re-running an import converges instead of duplicating. Available from the Settings "Import history" panel, a History view in the sidebar, or the `import-history` CLI command. Migration is a few minutes, not a fresh start.
+Autonomy and permission are independent. Use **Read-only**, **Request approval**, or **Full access** without changing the collaboration mode.
-### Keep one conversation going indefinitely
+## Stay in Control While It Works
-**The need:** Long tasks overflow the context window. Most agents respond by truncating history or summarizing everything at a threshold — so mid-task the agent forgets a decision you made an hour ago, or the window fills with stale tool output and quality falls off a cliff.
+DeepAgent is built for active collaboration, not fire-and-forget automation.
-**What DeepAgent does:** Your conversation is treated as a continuously maintained work state, not a growing chat log. Before every turn, the agent rebuilds a working set — the task anchor, the most recent exchanges verbatim, active file references, and only the older facts that are relevant right now — while the full history is archived durably and stays queryable. The working set is held to a hard fraction of the model window, so there's always room for the model to actually think and respond. You just keep talking; the agent keeps focus.
+- **Live steering:** send new guidance while a model turn or tool is running. The message is durably admitted and absorbed at the next safe provider-turn boundary without aborting in-flight work.
+- **Goal steering:** guidance sent to an active goal is folded into the next tick, preserving the current tool and plan state.
+- **Hot plan editing:** edit a running or paused goal. Stable step IDs, evidence, completed work, and the new plan version carry into the next tick.
+- **Explicit queueing:** queue a future activity when the instruction should begin after the current activity instead of changing it.
+- **Pause, resume, take over, or roll back:** every long-running workflow has a human control path and a durable audit trail.
-### Switch windows, fork freely, never lose memory
+## Memory You Can Inspect and Govern
-**The need:** You want to try two approaches, or hand the work to a fresh conversation, without starting from amnesia and without polluting the original thread.
+DeepAgent does not hide memory in an opaque prompt. Project state lives in typed, versioned documents with provenance, confidence, scope, status, and links.
-**What DeepAgent does:** Fork any conversation from a chosen message. The fork opens carrying the parent's memory up to that point, shows a full-width "derived from" marker at the top of its transcript, and nests folder-style under its origin in the session tree (subagents and forks alike, up to three levels deep). Knowledge flows up a scope hierarchy — what one session learns can be promoted to the whole project, and cross-project preferences live at the user-global layer — so switching windows is a clean handoff, not a reset.
+- Session-private working context stays with the current conversation.
+- Project-shared facts and decisions follow the repository.
+- User-global preferences can travel across projects.
+- Built-in skills and domain packs remain versioned system knowledge.
+- Sealed evaluator material stays audit-only and never enters model context.
-### Pick how much autonomy you want, per task
+Learning follows a governed lifecycle: evidence creates a candidate, isolated review or a human decision changes its status, and regression/ablation gates publish a reproducible knowledge snapshot. Rejection reasons remain durable so discarded patterns are not silently relearned.
-**The need:** Some asks want a fast answer in your exact words; others want the agent to plan first, or to run on its own until the job is done. One fixed behavior can't serve all three.
+The **Repo & Wiki** view makes this system readable. Browse knowledge and execution archives, search across the repository, follow docs-to-code links, inspect lineage, and promote useful run evidence into governed knowledge.
-**What DeepAgent does:** Three modes on the composer. **Auto** decides how to plan and act on your request. **Design** explores the problem and proposes a design before building anything. **Loop** turns a request into a supervised goal the agent works toward autonomously — iterating plan → execute → verify until the criteria are met — while you stay in control. You choose the autonomy level per task, not once for the whole tool.
+## Connected Context, Not a Larger Prompt
-### Choose how much the agent drives — and who writes the plan
+DeepAgent connects four views of the project:
-**The need:** Sometimes you want the agent to just take a request and run; sometimes you want to steer it with a plan you control; sometimes you've already written the plan and just want it executed faithfully.
+1. **Code graph:** files, symbols, imports, calls, diagnostics, and references.
+2. **Knowledge graph:** strategies, methodologies, facts, skills, and failure dossiers.
+3. **Project memory:** decisions, constraints, environment facts, and learned conventions.
+4. **Document graph:** plans, designs, worklogs, evaluations, run context, and evidence.
-**What DeepAgent does:** Three collaboration modes on the composer, picked from a single selector. **Auto** — the agent sets the objective, designs and plans as needed, and executes to completion. **Loop** — you describe the goal, the agent writes a `goal+plan.md` you can edit, then a supervised loop drives it to completion (plan → execute → verify per tick, with hard budget/step ceilings and objective completion checks). **Design** — you author `goal+plan.md` yourself and the agent executes your plan faithfully without redefining the goal. Orthogonal to mode, a permission control offers three presets — **Read-only**, **Request approval** (default), **Full access** — so autonomy and approval are separate, explicit choices.
+The Session V2 runner assembles context from explicit sources under a durable Context Epoch. It selects linked evidence within budget, records why each reference was admitted or rejected, and preserves the current goal, constraints, decisions, open questions, next steps, and relevant files during compaction.
-### Get a second opinion before high-risk decisions
+Prompt caching remains effective across long runs: stable system instructions stay byte-stable, while plans, steering, budgets, round results, and other volatile state are appended in a dedicated tail block.
-**The need:** Some decisions — a breaking migration, a security-sensitive change, an architecture call — deserve more than one confident pass agreeing with itself.
+## Built for Difficult Work
-**What DeepAgent does:** Convene an **Expert Panel** from the composer. Differentiated expert lenses (correctness, security, performance, architecture, repro) review the same frozen question independently, debate anonymously, and a deterministic (non-LLM) arbiter aggregates a verdict — with minority opinions preserved and a fail-closed bias toward escalating to you when the panel can't safely agree.
+### AI IDE
-### Read and govern what the agent knows
+Query code by symbol and intent instead of guessing file locations. DeepAgent combines LSP definitions, references, call chains, type information, diagnostics, rename previews, and cross-file evidence. Unsaved editor buffers participate in LSP updates, so analysis follows the code you are actually editing.
-**The need:** Persistent memory is only trustworthy if you can see it and correct it.
+### Domain packs
-**What DeepAgent does:** A **Repo & Wiki** view projects the four graphs into human-readable pages — browse and full-text-search the agent's knowledge, follow docs↔code cross-links, and edit governable Knowledge/Memory pages through the same evidence-gate the agent uses (Documents and Code stay read-only). A separate governance view lists learned facts grouped by project and global scope, so you approve what becomes durable.
+Composable domain packs add language, framework, platform, hardware, business, and risk expertise without hardcoding it into the core. Packs activate from the problem profile, resolve conflicts with stricter-policy-wins semantics, and are snapshot-locked for reproducible runs.
-### Go deep on genuinely hard problems
+### Specialist agents and Expert Panel
-**The need:** Complex work — an architecture decision, a tricky migration, a subtle bug — needs more than a single confident pass. It needs research, a second opinion, and someone actively trying to poke holes.
+DeepAgent can partition independent work across bounded, isolated workers. Write-capable subagents receive dedicated worktrees, return compact summaries and artifact references, and leave their full transcripts available for inspection.
-**What DeepAgent does:** At higher work strengths the primary agent decomposes the task, fans it out to focused subagents that research modules in parallel, synthesizes their findings, and then runs independent reviewers whose job is to *break* the plan rather than agree with it. Fan-out is bounded by a configurable concurrency ceiling, and live subagents surface in a session side panel and inline in the transcript so you can watch and jump into any of them.
+For high-risk decisions, convene an **Expert Panel**. Correctness, security, performance, architecture, and reproducibility lenses review the same frozen question, debate anonymously for up to three rounds, and feed a deterministic arbiter that preserves minority opinions and fails closed to human review.
-### Set a goal and let it run — supervised, not unsupervised
+### Team and agent messaging
-**The need:** Some work is a long haul — a migration, a green-the-suite push, a multi-step feature. You want to hand it off and walk away, but "walk away" can't mean "lose control."
+Project IM brings people and agents into the same thread. Mention an agent to start a scoped run with project context, stream its progress, inspect its artifacts, and keep the answer attached to the conversation that requested it.
-**What DeepAgent does:** Loop mode drives an autonomous goal loop against an objectively decidable finish line (tests pass, no diagnostics, reviewer clean, plan complete). It runs plan → execute → verify → iterate in the background, with hard ceilings on ticks, tokens, wall-clock, and cost so it can never run away. A status bar shows live progress; you can hot-edit the plan mid-run, pause and resume from exactly where it stopped, or take over — a takeover pauses autonomy escalation and hands control back to you. A goal with steps that need a human never reports "done"; it routes to you. Autonomy is a dial you hold, not a switch you flip and hope.
+## DeepAgent Core V4.1
-### Get a second opinion that actually argues
+V4.1 brings the complete DeepAgent control plane together:
-**The need:** For a high-stakes decision, one confident answer isn't enough — you want independent experts who see the same evidence, disagree, and defend their positions.
-
-**What DeepAgent does:** Convene an Expert Panel on the current conversation from the composer. Pick single-round for a quick multi-lens review, or multi-round for a real debate: panelists (correctness, security, design, …) each render a verdict, then see each other's *anonymized* opinions and revise across up to three rounds — identity stripped so nobody anchors on "the security expert said." An arbiter synthesizes the surviving verdict. Fan-out and rounds are bounded, and every opinion (including the losers') is archived. The panel convenes on demand, or a running goal loop can convene it at high-risk decision points.
-
-### Chat with your team and your agents in one place
-
-**The need:** Coordinating with teammates and driving agents usually happens in two different tools.
-
-**What DeepAgent does:** A per-project group chat lives in the session side panel. @mention an agent as a chat member and it runs the full agent loop — query code, generate, fix — pulling project knowledge and recent messages for context, then replies inline with live progress streaming.
-
-## How It Works
-
-Each capability above is served by a control-plane primitive underneath. These are the parts a plain runtime doesn't have.
-
-**Four-graph unification** — Code, knowledge, project memory, and the document graph are unified into one typed, bidirectionally-linked store. When the agent pulls context, a change to a symbol surfaces the design decisions, past diagnoses, and knowledge actually linked to it — connected context, not four disconnected keyword searches.
-
-**Domain packs** — 140+ composable knowledge packages spanning languages, frameworks, platforms (cloud, Kubernetes, CI), hardware, and business/risk domains (security, privacy, compliance). Each pack bundles typed documents (strategies, methodologies, knowledge, skills, failure dossiers) with detectors that auto-activate the right packs for your task; conflicts resolve stricter-policy-wins, and the active set is version-locked so a run is reproducible. Core stays domain-neutral — expertise is data on disk, not hardcoded.
-
-**Tiered knowledge invocation** — A monotonic strength ladder (`general → high → xhigh → max → ultra`) gates how much control-plane machinery engages. `general` stays close to the plain runtime — fast and cheap. Higher rungs progressively unlock durable knowledge, project handoff summaries, heavier strategy/methodology tiers, and multi-agent orchestration. You pay for depth only when you dial it up.
-
-**Self-learning** — After work lands, the agent proposes candidate knowledge, facts, and methodologies. Promotion is evidence-gated (a test passed, a diagnostic cleared, a validation confirmed) and user-controllable — durable knowledge is carried over deliberately, not silently guessed. Session-stable conclusions consolidate into project memory over time, so the next session starts smarter about *your* codebase.
-
-**Supervised autonomy** — The goal loop, expert panel, and multi-agent fan-out run on an event-driven substrate with the guardrails autonomy needs: hard budget ceilings (ticks/tokens/wall-clock/cost), a stall detector that stops rather than spins, layered permission and safety gates that fail closed, human takeover that pauses escalation, and a full audit trail written back to the document graph. An Agent Dashboard surfaces task success rate, conflicts, and dead-letter events. Autonomy is bounded and observable by construction — never a black box you can't stop.
+- **Durable Session V2:** prompt admission is persisted before execution; exact retries do not duplicate user intent; same-session wakes coalesce safely.
+- **One provider-turn contract:** native and AI SDK providers share the same budget, permission, artifact, audit, learning, and close lifecycle.
+- **Single durable truth:** DocumentStore owns documents, plans, learning candidates, governance state, and version conflicts through atomic, recoverable writes.
+- **Event-driven Agent OS:** durable events, priority routing, backpressure, worker claims, leases, handoffs, retries, dead-letter recovery, and distributed placement coordinate autonomous work.
+- **Consumer-driven goals:** `goal.tick.requested` claims and executes one idempotent tick, records facts, and schedules the next tick only when the durable goal remains eligible.
+- **Human oversight:** approval queues, trace correlation, takeover, rollback, Wiki archives, notifications, and organization/workspace isolation remain part of the execution path.
+- **Secure integrations:** MCP credentials use environment references or native OS secret storage; catalog risk, runtime permissions, trusted sources, and tool capability checks fail closed.
## Installation
@@ -150,7 +149,7 @@ On your next session, when you ask to add rate limiting elsewhere, the agent alr
## Architecture
-```
+```text
┌─────────────────────────────────────────────────────────────┐
│ Control Plane (DeepAgent additions) │
│ • Four-graph unified store (code + knowledge + memory + doc)│
@@ -179,24 +178,56 @@ On your next session, when you ask to add rate limiting elsewhere, the agent alr
└─────────────────────────────────────────────────────────────┘
```
-DeepAgent's control plane operates at provider-turn boundaries: it selects context before each model call and writes evidence back into the document graph afterward. It does not replace opencode's runtime — it layers on top.
+The full architecture and its invariants are documented in [Architecture & Design](design/README.md).
+
+## Build From Source
+
+DeepAgent Code uses Bun 1.3.14.
+
+```bash
+git clone https://github.com/deepagent-ltd/deepagent-code.git
+cd deepagent-code
+bun install
+```
+
+Start the Desktop app:
+
+```bash
+bun run dev:desktop
+```
+
+Start the terminal experience:
+
+```bash
+bun run dev
+```
+
+Run a one-shot task:
+
+```bash
+bun run --cwd packages/deepagent-code dev run "add rate limiting to /api/users"
+```
+
+Import existing Codex or Claude Code history:
+
+```bash
+bun run --cwd packages/deepagent-code dev import-history --from codex --dry-run
+```
## Documentation
-- [Architecture & Design](design/README.md) — Control plane, code intelligence, MCP security model
-- [Security Policy](SECURITY.md) — Vulnerability reporting, known limitations
-- [Privacy Policy](PRIVACY.md) — Data handling and storage
-- [Contributing](CONTRIBUTING.md) — Development setup and guidelines
-- [Changelog](CHANGELOG.md) — Release history
+- [Architecture & Design](design/README.md)
+- [Security Policy](SECURITY.md)
+- [Privacy Policy](PRIVACY.md)
+- [Contributing](CONTRIBUTING.md)
+- [Changelog](CHANGELOG.md)
## License & Attribution
-DeepAgent Code is licensed under **AGPL-3.0-or-later**. If you modify and run it as a network service, you must make your source code available to users.
+DeepAgent Code is licensed under **AGPL-3.0-or-later**. If you modify and run it as a network service, you must make the corresponding source available to its users.
-This project is derived from [opencode](https://github.com/sst/opencode) (MIT License). See [NOTICE](NOTICE) for the upstream license and attribution. No endorsement by opencode or its contributors is implied.
+DeepAgent Code is derived from [opencode](https://github.com/sst/opencode) under the MIT License. See [NOTICE](NOTICE) for upstream attribution. No endorsement by opencode or its contributors is implied.
---
-
- Built by DeepAgent
-
+Built by DeepAgent
diff --git a/README.zh.md b/README.zh.md
index e59e34c5..efe97275 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -6,7 +6,7 @@
-具备持久记忆、自主目标与控制平面的 AI 编程智能体
+会记忆、会规划、会协作,也能把工作真正做完的 AI 编程智能体
English |
@@ -14,95 +14,94 @@
Enterprise 版本
-桌面版 v1.3
+桌面版 1.4.1 · DeepAgent Core V4.1
---
-DeepAgent Code 是一个构建在持久文档记忆之上的 AI 编程智能体。它保留了 [opencode](https://github.com/sst/opencode) 的运行时基座,并在其上叠加了一层控制平面——让智能体不再像一次性的对话工具,而更像一位记得住你项目、会替你打磨模糊需求、能对硬骨头深挖到底的队友。
+DeepAgent Code 是一套面向长期工作的 AI 编程工作区。它把生产级编程智能体运行时与持久会话、相互连通的项目记忆、实时计划、代码智能、多智能体协作和人类监督组合在一起。
-下面的每一项特性,都从一个真实的需求出发——一个普通编程智能体(包括 opencode)尚未满足的需求——再向下讲到我们为满足它而构建的底层架构。
+你可以让它完成一次小改动,在任务运行中继续补充指令,把一场迁移交给带客观完成判据的目标回路,或者召集多位专家共同审阅一项决策。DeepAgent 会让工作在多轮对话、进程重启、工具调用、团队成员和不同项目之间保持连贯。
-## 你可以做什么
+## 一个工作区,三种协作方式
-### 把历史一起带过来——换工具不必从零开始
+按任务选择最合适的协作方式:
-**需求:** 你已经在另一个智能体里——Codex 或 Claude Code——积累了几个月的上下文,而换工具通常意味着把这一切统统丢下:那些对话、沉淀下来的记忆、你调好的技能。单是这份代价,就足以把人留在早该告别的工具上。
+| 模式 | 你提供 | DeepAgent 负责 |
+|---|---|---|
+| **自动(Auto)** | 一项需求 | 自行明确目标,按需设计和规划,再端到端执行 |
+| **循环(Loop)** | 一个目标 | 生成可编辑的 `goal+plan.md`,按计划、执行、校验、迭代逐 tick 推进 |
+| **设计(Design)** | 你编写的 `goal+plan.md` | 忠实执行你的设计,不重新定义目标或完成判据 |
-**DeepAgent 的做法:** 一键导入你已有的历史。把它指向一个 Codex 或 Claude Code 的安装目录,它就会把你的会话、记忆和技能热导入 DeepAgent——读取各工具的本地格式,归一化后重放进文档图,让导入的对话表现得和原生对话一样。导入过程中会对密钥做脱敏,导入的项目彼此隔离、不会和你正在进行的工作冲突,重复导入会收敛而不是产生重复。入口有三处:设置里的"导入历史"面板、侧栏的历史视图,以及 `import-history` 命令行。迁移只需几分钟,而不是推倒重来。
+自主程度和权限相互独立。你可以在不改变协作模式的情况下选择**只读**、**请求批准**或**完全访问**。
-### 一直对话下去,不必换新
+## 它在工作,你仍然掌控全局
-**需求:** 长任务会撑爆上下文窗口。多数智能体的应对方式是截断历史,或在阈值处一刀切地整体摘要——于是任务进行到一半,智能体忘了你一小时前拍的板,或者窗口被陈旧的工具输出塞满,质量断崖式下跌。
+DeepAgent 为持续协作而设计,不是一个发出指令后只能等待的黑盒。
-**DeepAgent 的做法:** 你的对话被当作一份持续维护的工作状态,而不是不断变长的聊天记录。每一轮开始前,智能体都会重建一份工作面——任务锚点、最近若干轮的原文、活跃的文件引用,以及此刻真正相关的旧事实——同时完整历史被持久归档、随时可查。工作面被严格控制在模型窗口的一个固定比例以内,永远给模型留足思考和作答的余地。你只管一直说,智能体替你保持专注。
+- **实时 Steering:** 模型或工具仍在运行时继续发送指导。消息会先持久化,再在下一个安全的供应商轮次边界被吸收,不会中断在飞工作。
+- **Goal Steering:** 发给活跃目标的指导会进入下一个 tick,同时保留当前工具状态和计划状态。
+- **运行中计划热编辑:** 编辑正在运行或已暂停的目标。稳定的步骤 ID、证据、已完成工作和新计划版本会一起进入下一 tick。
+- **显式排队:** 当一条指令应该在当前 activity 结束后独立开始时,把它放入未来队列,而不是改变当前工作。
+- **暂停、恢复、接管或回滚:** 每个长跑流程都有清晰的人类控制路径和持久审计记录。
-### 随手分叉、切换窗口,记忆不丢
+## 看得见、管得住的记忆
-**需求:** 你想同时试两条思路,或把工作交给一个全新的对话,却不想从头失忆重来,也不想把原来的线程搅乱。
+DeepAgent 不会把记忆藏在不可见的提示词里。项目状态保存在带类型、版本、来源、置信度、作用域、状态和链接的文档中。
-**DeepAgent 的做法:** 从任意一条消息分叉当前对话。分叉出的新对话会继承父对话到该点为止的记忆,在时间线顶部显示一条贯穿窗口的"从对话派生"分割线,并像文件夹一样嵌套挂在来源对话之下(子 agent 与分叉同理,最多三层深)。知识沿作用域层级向上流动——单个会话学到的东西可提升到整个项目,跨项目的偏好则沉淀在用户全局层——所以换窗口是一次干净的交接,而非一次清零重来。
+- 会话私有工作上下文只属于当前对话。
+- 项目共享事实与决策跟随代码仓库。
+- 用户全局偏好可以跨项目使用。
+- 内置技能与领域包保持系统级版本管理。
+- 封存的评测材料仅用于审计,永不进入模型上下文。
-### 自主程度,按任务自己挑
+学习遵循可治理的生命周期:证据生成候选,隔离审阅或人工决策改变候选状态,回归与消融门发布可复现的知识快照。拒绝理由会持久保存,因此被淘汰的模式不会在后台被悄悄重新学习。
-**需求:** 有些提问想要一个快速、原话作答;有些希望智能体先规划;还有些希望它自己跑到把活干完为止。一种固定行为伺候不了这三种。
+**仓库与百科(Repo & Wiki)** 让这套系统对人可读。你可以浏览知识与执行档案、搜索整个仓库、沿文档到代码的链接探索上下文、检查来源链,并把有价值的运行证据升格为受治理知识。
-**DeepAgent 的做法:** 输入框上有三种模式。**自动(Auto)** 由智能体决定如何规划并执行你的请求。**设计(Design)** 先探索问题、给出设计方案,再动手构建。**循环(Loop)** 把请求变成一个受监督的目标,智能体朝它自主推进——不断地"规划 → 执行 → 校验"直到满足完成判据——而掌控权始终在你手里。自主程度按任务来选,而不是为整个工具一次性定死。
+## 相互连接的上下文,而不是更长的提示词
-### 选择智能体驱动的方式——以及由谁来写计划
+DeepAgent 把项目的四个视图连接在一起:
-**需求:** 有时你希望智能体拿到需求就自己跑;有时你想用一份由自己掌控的计划来引导它;有时你已经把计划写好了,只需要它忠实执行。
+1. **代码图:** 文件、符号、导入、调用、诊断与引用。
+2. **知识图:** 策略、方法论、事实、技能与故障档案。
+3. **项目记忆:** 决策、约束、环境事实与已学习的项目约定。
+4. **文档图:** 计划、设计、工作日志、评测、运行上下文与证据。
-**DeepAgent 的做法:** 输入框上的协作模式选择器提供三档。**自动** ——智能体自行定目标、做计划、执行到完成。**目标** ——你说明需求,智能体生成一份你可以编辑的 `goal+plan.md`,再由监督循环驱动执行(计划→执行→验证逐步推进,有硬性预算/步数上限和客观完成判据)。**设计** ——你自己写好 `goal+plan.md`,智能体读取并忠实执行你的方案,不会重新定义目标。与协作模式正交,权限控制提供三个预设——**只读**、**请求批准**(默认)、**完全访问**——自主程度与审批方式是两个独立的、明确的选项。
+Session V2 运行器在持久 Context Epoch 下从明确的 Context Source 装配上下文。它在预算内选择相互关联的证据,记录每条引用为什么被准入或拒绝,并在压缩时保留当前目标、约束、决策、开放问题、后续步骤与相关文件。
-### 在高风险决策前听一次会诊
+长跑任务也能持续命中提示词缓存:稳定 system 指令保持字节级稳定,计划、Steering、预算、轮次结果等易变状态只追加到独立的尾部区块。
-**需求:** 有些决策——一次破坏性迁移、一个安全敏感的改动、一个架构抉择——值得不止一次自信的作答自我认同。
+## 为困难工作而生
-**DeepAgent 的做法:** 在输入框旁召集**专家团**。差异化的专家视角(正确性、安全、性能、架构、可复现)对同一个冻结问题各自独立审阅,匿名辩论,再由一个确定性(非 LLM)的仲裁者聚合裁定——保留少数派意见,并在专家团无法安全达成一致时偏向将决策权升级给你。
+### AI IDE
-### 读懂并治理智能体所知道的
+按符号和意图查询代码,不再猜文件位置。DeepAgent 组合 LSP 定义、引用、调用链、类型信息、诊断、重命名预览与跨文件证据。未保存的编辑器 buffer 也会实时进入 LSP,因此分析看到的是你正在编辑的代码。
-**需求:** 持久记忆只有在你能看见、能纠错的情况下才值得信任。
+### 领域包
-**DeepAgent 的做法:** **仓库与百科**视图把四张图投影成人类可读的页面——浏览并全文检索智能体的知识,跟随文档↔代码的交叉链接,并通过与智能体相同的证据门编辑可治理的知识/记忆页面(文档与代码页面只读)。单独的知识治理视图按项目和全局分组列出已学事实,由你审批哪些成为永久知识。
+可组合的领域包提供语言、框架、平台、硬件、业务与风险知识,而不把专业逻辑硬编码进内核。领域包根据问题画像自动激活,以“更严格策略优先”解决冲突,并锁定快照以保证运行可复现。
-### 对真正的难题深挖到底
+### 专业子智能体与 Expert Panel
-**需求:** 复杂的工作——一个架构决策、一次棘手的迁移、一个隐蔽的 bug——需要的不止一次自信的单程作答。它需要调研、需要第二意见、需要有人主动来挑刺。
+DeepAgent 可以把独立工作拆分给数量有界、相互隔离的 Worker。具备写权限的子智能体获得独立 worktree,只向父会话返回紧凑摘要和工件引用,完整执行记录仍可随时查看。
-**DeepAgent 的做法:** 在更高的工作强度下,主智能体会拆解任务,扇出给专注的子 agent 并行调研各个模块,综合它们的发现,再运行独立的审阅者——审阅者的职责是"击破"方案,而不是附和。扇出受可配置的并发上限约束;运行中的子 agent 会出现在会话侧栏面板和时间线内联卡片里,你可以旁观并随时跳进任意一个。
+高风险决策可以召集 **Expert Panel**。正确性、安全、性能、架构与可复现性等专家视角审阅同一个冻结问题,进行最多三轮匿名辩论,再由确定性仲裁器生成裁定。少数派意见会被保留,无法安全达成一致时会失败关闭并交给人类。
-### 定个目标让它自己跑——是受监督,而非放养
+### 团队与智能体消息
-**需求:** 有些工作是场持久战——一次迁移、一轮把测试全刷绿、一个多步骤的特性。你想把它交出去然后走开,但"走开"不能等于"失控"。
+项目 IM 把团队成员和智能体放进同一条讨论。@ 某个智能体即可启动有明确作用域的运行,使用项目上下文、流式展示进度、关联执行工件,并把答案留在发起任务的对话里。
-**DeepAgent 的做法:** 循环模式驱动一个自主目标回路,朝着一条可客观判定的终点线推进(测试通过、无诊断、审阅无异议、计划完成)。它在后台跑"规划 → 执行 → 校验 → 迭代",并对轮次、令牌、墙钟时间和成本设有硬上限,绝不会失控狂奔。状态条实时显示进度;你可以在运行中热编辑计划、暂停后从中断处精确恢复,或直接接管——接管会暂停自主升级、把控制权交还给你。一个包含需要人工处理步骤的目标绝不会谎报"完成",而是转交给你。自主是你握在手里的旋钮,不是拨一下就听天由命的开关。
+## DeepAgent Core V4.1
-### 要一个真会争论的第二意见
+V4.1 把完整的 DeepAgent 控制平面汇聚在一起:
-**需求:** 面对高风险决策,一个自信的答案不够——你想要一批独立专家,看着同样的证据,各持己见、各自辩护。
-
-**DeepAgent 的做法:** 从输入框就当前对话召集专家团。选单轮做一次快速的多视角评审,或选多轮进行真正的辩论:各位专家(正确性、安全、设计……)先各自给出裁定,然后看到彼此**匿名化**的意见并在最多三轮里修正——身份被抹去,谁都无法因"是安全专家说的"而锚定。一位仲裁者综合出最终存活的裁定。扇出与轮数都有界,每一条意见(包括落败的)都会被归档。专家团可按需召集,运行中的目标回路也能在高风险决策点上召集它。
-
-### 团队与智能体,在同一处协作
-
-**需求:** 和队友协调、驱动智能体,通常发生在两个不同的工具里。
-
-**DeepAgent 的做法:** 每个项目的群聊就在会话侧栏里。把某个智能体 @ 进来当作聊天成员,它便会跑完整的智能体回路——查代码、生成、修复——拉取项目知识与最近消息作为上下文,然后带着实时进度在群里内联回复。
-
-## 它是怎么做到的
-
-上面每一项能力,底层都由一个控制平面原语来支撑。这些正是普通运行时所没有的部分。
-
-**四图合一** — 代码图、知识图、项目记忆、文档图被统一进同一个带类型、双向链接的存储。当智能体拉取上下文时,对某个符号的改动会连带浮现出与它真正相连的设计决策、过往诊断和知识——是连通的上下文,而不是四次互不相干的关键词检索。
-
-**领域包** — 140+ 个可组合的知识包,覆盖编程语言、框架、平台(云、Kubernetes、CI)、硬件,以及业务/风险领域(安全、隐私、合规)。每个包捆绑了带类型的文档(策略、方法论、知识、技能、故障档案)与探测器,能为你的任务自动激活相应的包;冲突按"更严策略优先"消解,激活的集合会被版本锁定,从而让一次运行可复现。内核保持领域中立——专业能力是磁盘上的数据,而非写死的代码。
-
-**知识分级调用** — 一条单调递增的强度阶梯(`general → high → xhigh → max → ultra`)决定控制平面机器启动到什么程度。`general` 贴近原生运行时——又快又省。越往上,逐级解锁持久知识、项目交接摘要、更重的策略/方法论层,以及多智能体编排。只有当你上调档位时,才为深度付费。
-
-**自学习** — 工作落地后,智能体会提出候选的知识、事实与方法论。晋升是证据门控的(一项测试通过、一条诊断清零、一次校验确认)且由用户掌控——持久知识是被有意结转的,而非后台悄悄猜出来的。会话中稳定的结论会随时间巩固进项目记忆,于是下一次会话对*你的*代码库上手更聪明。
-
-**受监督的自主** — 目标回路、专家团与多智能体扇出都跑在一套事件驱动的底座上,并带着自主所必需的护栏:硬性预算上限(轮次/令牌/墙钟/成本)、宁停不空转的停滞检测、层层失败即关闭的权限与安全门、暂停升级的人工接管,以及写回文档图的完整审计轨迹。一个 Agent 面板会呈现任务成功率、冲突与死信事件。自主在构造上就是有界且可观测的——绝不是一个你停不下来的黑盒。
+- **持久 Session V2:** prompt 先持久准入、再调度执行;精确重试不会复制用户意图;同一 Session 的唤醒会安全合并。
+- **统一供应商轮次合同:** native 与 AI SDK provider 共享预算、权限、工件、审计、学习和关闭生命周期。
+- **单一持久真相:** DocumentStore 通过原子、可恢复写入统一管理文档、计划、学习候选、治理状态和版本冲突。
+- **事件驱动 Agent OS:** 持久事件、优先级路由、回压、Worker claim、租约、handoff、重试、死信恢复与分布式 placement 协调自主工作。
+- **消费者驱动 Goal:** `goal.tick.requested` 每次认领并执行一个幂等 tick,记录事实,并只在持久目标仍满足条件时调度下一 tick。
+- **人类监督:** 审批队列、全链路 trace、接管、回滚、Wiki 档案、通知,以及组织和 workspace 隔离始终位于执行路径上。
+- **安全集成:** MCP 凭据使用环境变量引用或原生操作系统 secret storage;目录风险、运行时权限、可信来源和工具 capability 逐层失败关闭。
## 安装
@@ -150,7 +149,7 @@ deepagent-code "为 /api/users 端点添加限流"
## 架构
-```
+```text
┌─────────────────────────────────────────────────────────────┐
│ 控制平面(DeepAgent 新增) │
│ • 四图合一存储(代码 + 知识 + 记忆 + 文档) │
@@ -179,24 +178,56 @@ deepagent-code "为 /api/users 端点添加限流"
└─────────────────────────────────────────────────────────────┘
```
-DeepAgent 的控制平面在供应商轮次的边界上运作:在每次模型调用前挑选上下文,调用后把证据写回文档图。它不替换 opencode 的运行时——只是叠加在其之上。
+完整架构与不变量见 [架构与设计](design/README.md)。
+
+## 从源码运行
+
+DeepAgent Code 使用 Bun 1.3.14。
+
+```bash
+git clone https://github.com/deepagent-ltd/deepagent-code.git
+cd deepagent-code
+bun install
+```
+
+启动桌面应用:
+
+```bash
+bun run dev:desktop
+```
+
+启动终端界面:
+
+```bash
+bun run dev
+```
+
+执行一次性任务:
+
+```bash
+bun run --cwd packages/deepagent-code dev run "为 /api/users 添加限流"
+```
+
+导入已有 Codex 或 Claude Code 历史:
+
+```bash
+bun run --cwd packages/deepagent-code dev import-history --from codex --dry-run
+```
## 文档
-- [架构与设计](design/README.md) — 控制平面、代码智能、MCP 安全模型
-- [安全策略](SECURITY.md) — 漏洞上报、已知限制
-- [隐私政策](PRIVACY.md) — 数据处理与存储
-- [贡献指南](CONTRIBUTING.md) — 开发环境与规范
-- [更新日志](CHANGELOG.md) — 发布历史
+- [架构与设计](design/README.md)
+- [安全策略](SECURITY.md)
+- [隐私策略](PRIVACY.md)
+- [贡献指南](CONTRIBUTING.md)
+- [更新日志](CHANGELOG.md)
## 许可与署名
-DeepAgent Code 采用 **AGPL-3.0-or-later** 许可。如果你修改并将其作为网络服务运行,必须向用户提供你的源代码。
+DeepAgent Code 使用 **AGPL-3.0-or-later** 许可。如果你修改本项目并将其作为网络服务运行,必须向服务用户提供对应源代码。
-本项目衍生自 [opencode](https://github.com/sst/opencode)(MIT 许可)。完整的上游许可与署名见 [NOTICE](NOTICE)。本项目不暗示 opencode 或其贡献者的任何背书。
+DeepAgent Code 基于 [opencode](https://github.com/sst/opencode) 的 MIT 许可代码演进而来。上游署名见 [NOTICE](NOTICE)。本项目不暗示 opencode 或其贡献者的任何背书。
---
-
- 由 DeepAgent 打造
-
+Built by DeepAgent
diff --git a/design/README.md b/design/README.md
index 86c26ebc..bd41fcfb 100644
--- a/design/README.md
+++ b/design/README.md
@@ -1,120 +1,266 @@
-# DeepAgent Code — Architecture & Design
+# DeepAgent Code Architecture & Design
-> **Public design overview.** Internal implementation details and roadmap docs live in the private `docs/` tree (not version-controlled). This directory contains the publicly visible architectural narrative.
+> Public architecture baseline for DeepAgent Core V4.1.
----
+DeepAgent Code is a document-centered, event-driven AI coding system. It combines a coding-agent runtime with a durable control plane that owns context, planning, learning, collaboration, safety, and human oversight.
-## What is DeepAgent Code?
+The architecture is designed around one requirement: a long-running agent must remain correct and governable after many model turns, tool calls, user interventions, process restarts, and worker handoffs.
-DeepAgent Code is an AI coding agent that adds a **control plane** on top of the [opencode](https://github.com/sst/opencode) runtime. It keeps the proven opencode foundations (runtime, tool, MCP, session, provider stack) and layers in:
+## Design Principles
-- **Durable document memory** — knowledge base with retrieval gates, dedup, and merge
-- **Context assembly** — selective, evidence-backed context building (not raw file dumps)
-- **Plan system** — structured task planning with staleness detection and rollback
-- **Failure triage** — three-tier classifier (auto-fixable / needs-narrowing / not-auto-fixable)
-- **Domain adapters** — pluggable domain packs for specialized workflows
-- **AI IDE microservice** — LSP-backed semantic code navigation via `code_intel`
-- **MCP catalog** — curated, one-click-enable MCP servers with safety tiers
+### One durable truth per concept
----
+Sessions, inputs, plans, documents, goals, events, approvals, and learning decisions each have one authoritative durable representation. In-memory state is a cache or an ownership hint, never a competing source of truth.
-## Architectural Principles
+### Admission is separate from execution
-### 1. Enhance, don't replace
+A user instruction is durably admitted before execution is scheduled. A successful API response therefore means the instruction is recorded, not merely present in a process-local queue.
-DeepAgent is built **on top of** the opencode agent/runtime/session/tool/MCP stack. It does not rewrite the execution engine, tool system, or provider layer. The default agent behavior is not degraded. The lower-strength `general` mode stays close to the inherited runtime contract.
+### One provider-turn contract
-### 2. Control-plane only
+Native and AI SDK providers pass through the same model-turn boundary. Budget, permissions, tool policy, prompt assembly, artifacts, audit, learning, and close semantics cannot be bypassed by selecting another provider implementation.
-DeepAgent is responsible for **strategy / context / budget / audit / verification / document graph**. It does not directly spawn LSP processes or execute MCP tools — those go through the existing `LSP.Service` and `MCP.Service` respectively.
+### Context is selected, not accumulated
-### 3. Full tool output does not enter context
+The model receives a bounded working set assembled from explicit Context Sources. Full tool output and durable history remain referenceable artifacts; only admitted summaries, evidence, and snippets enter the active context.
-Per the deterministic task control contract: raw LSP results, diagnostic dumps, and capability indexes are written to **evidence artifacts** (ref-linked, tool-only visibility). Only summaries and `file:line` snippets appear in the model context.
+### Safety fails closed
-### 4. Fail-closed on safety
+External events, credentials, tools, paths, autonomy, worker placement, and outbound messages are checked at their execution boundaries. Missing identity, trust, capability, or approval never widens access.
-MCP catalog entries default to **not connected** (zero startup overhead). Dangerous write operations (force-push, DROP, file delete) require explicit approval. Read-only DB connections enforce restricted-mode at the server level.
+### Humans can always intervene
----
+Steering, plan editing, approval, pause, resume, takeover, rollback, and review are part of the runtime contract. They are not dashboard-only controls layered over an autonomous black box.
-## Component Map
+## System Map
-```
-┌─────────────────────────────────────────────────────────────┐
-│ DeepAgent Control Plane │
-│ │
-│ ┌──────────────┐ ┌─────────────┐ ┌───────────────────┐ │
-│ │ Plan System │ │ Doc Memory │ │ Failure Triage │ │
-│ │ (task/plan) │ │ (knowledge │ │ (3-tier classify)│ │
-│ └──────┬───────┘ │ store) │ └─────────┬─────────┘ │
-│ │ └──────┬──────┘ │ │
-│ ┌──────▼──────────────────▼──────────────────▼───────────┐ │
-│ │ Agent Gateway (core) │ │
-│ │ audit · budget · permission · capability index │ │
-│ └──────┬─────────────────────────────────────────────────┘ │
-└─────────│───────────────────────────────────────────────────┘
- │
-┌─────────▼──────────────────────────────────────────────────┐
-│ opencode Foundation (unchanged) │
-│ │
-│ Session ─── Tool Registry ─── MCP Service │
-│ │ │ │ │
-│ Provider LSP Service 38 lang servers │
-│ (Claude/…) code_intel tool + MCP catalog │
-└────────────────────────────────────────────────────────────┘
+```text
+┌─────────────────────────────────────────────────────────────────────┐
+│ Experience │
+│ Desktop · Web · TUI · IM · Repo & Wiki · Expert Panel · Oversight │
+└──────────────────────────────┬──────────────────────────────────────┘
+ │
+┌──────────────────────────────▼──────────────────────────────────────┐
+│ Durable Session Runtime │
+│ Session V2 · System Context · Context Epoch · Steering · Queue │
+│ Prompt cache policy · Provider-turn lifecycle · Tool materialization│
+└──────────────────────────────┬──────────────────────────────────────┘
+ │
+┌──────────────────────────────▼──────────────────────────────────────┐
+│ DeepAgent Control Plane │
+│ PlanController · GoalController · Context Graph · Learning │
+│ Event Router · Scheduler · Worker Pool · Handoff · Security gates │
+└──────────────────────────────┬──────────────────────────────────────┘
+ │
+┌──────────────────────────────▼──────────────────────────────────────┐
+│ Durable State │
+│ DocumentStore · Session/Event database · Event Bus · Audit/Artifacts│
+└──────────────────────────────┬──────────────────────────────────────┘
+ │
+┌──────────────────────────────▼──────────────────────────────────────┐
+│ Execution Services │
+│ Provider · Tool · LSP · MCP · Git/Worktree · Debug · Profile │
+└─────────────────────────────────────────────────────────────────────┘
```
----
+## Session V2
-## code_intel — AI IDE Microservice
+Session V2 separates durable prompt admission from model execution.
-The `code_intel` tool wraps the LSP stack as a **symbol-driven semantic API**. The agent specifies a symbol name and an intent; `code_intel` resolves line/column coordinates internally and returns `file:line` + code snippets.
+### Prompt admission
-```typescript
-code_intel({ symbol: "AgentGateway.open", intent: "overview" })
-// → definition + type + references + callers + callees + doc summary
-// full detail → evidence artifact (ref only in context)
-```
+- Each prompt creates one durable `session_input` before scheduling work.
+- Reusing a Session ID adopts that Session rather than creating a parallel execution entity.
+- Reusing a prompt message ID is accepted only for an exact retry with the same Session, content, and delivery mode.
+- Conflicting ID reuse fails instead of silently reconciling different user intent.
+- A prompt can be admitted without waking execution when the caller requests admit-only behavior.
+
+### Delivery vocabulary
+
+| Delivery | Meaning |
+|---|---|
+| normal turn | Start a normal activity when the Session is idle |
+| `steer` | Add guidance to the active activity at the next safe provider-turn boundary |
+| `goal_steer` | Add guidance to the next Goal tick |
+| `queue` | Open a future FIFO activity after the active activity settles |
+| interrupt | Target the active process-local ownership chain immediately |
+
+Steering never aborts an in-flight tool or stream. The input is persisted first, absorbed in stable order, and materialized into history exactly once.
+
+### Execution ownership
+
+`SessionExecution` is process-global and keyed by Session ID. A drain discovers placement from durable Session location only when it starts. SessionRunner, model resolution, tools, permissions, and filesystem services remain Location-scoped.
+
+Same-Session resumes join one coordinator; advisory wakes coalesce; different Sessions can run concurrently. Every provider turn performs one explicit `llm.stream(request)` call and reloads projected history before durable continuation.
+
+### Prompt cache invariant
+
+The system prompt is split into a byte-stable prefix and one append-only volatile tail:
+
+- Agent instructions, stable policy, and System Context baseline stay in the prefix.
+- Round state, budgets, plan snapshots, prior results, fan-out decisions, and steering stay in the tail.
+- OpenAI-compatible providers use a stable Session cache key; providers with cache markers use their protocol-native breakpoint.
+- Prefix hash and cache outcomes distinguish normal compaction misses from accidental prefix drift.
+
+## Document System
+
+DocumentStore is the durable body for DeepAgent state. Documents carry:
+
+- a stable ID and monotonic version;
+- type, scope, status, domain, tags, and description;
+- provenance and evidence references;
+- confidence, sensitivity, and approval risk;
+- typed links such as `supports`, `blocks`, `conflicts`, `validates`, `supersedes`, `contains`, `imports`, and `calls`.
+
+Writes use atomic replacement and conflict detection. Concurrent handles observe one authority, process-level writers coordinate through lock/CAS semantics, and migrations are incremental, restartable, and integrity-checked.
+
+### Scope
+
+| Scope | Purpose |
+|---|---|
+| `session-private` | Current conversation and run-local state |
+| `project-shared` | Knowledge and decisions shared by one project |
+| `user-global` | Cross-project preferences and explicitly promoted knowledge |
+| `public-system` | Built-in skills and domain-pack documents |
+| `sealed` | Audit/evaluator material that cannot enter model context |
+
+Plans, run context, worklogs, designs, diagnoses, evaluations, knowledge, memory, strategies, methodologies, skills, and failure dossiers share this document algebra instead of maintaining separate storage models.
+
+## System Context and the Four Graphs
+
+The Context System connects four projections over the same durable knowledge surface:
+
+1. **Code:** files, symbols, imports, calls, definitions, references, diagnostics.
+2. **Knowledge:** facts, strategies, methodologies, skills, and failure dossiers.
+3. **Memory:** decisions, constraints, project conventions, environment facts, and handoffs.
+4. **Documents:** plans, designs, run context, evidence, worklogs, and evaluations.
+
+Context Sources produce typed observations from their domains. Session-owned selection applies budget, relevance, scope, sensitivity, evidence strength, conflict, and snapshot rules. A Context Epoch records the selected baseline so a provider turn is reproducible and observable.
+
+The Event Router can attach a context strategy to an event. SessionRunner executes that strategy in the target Location, records query and admission decisions in trace, and degrades safely when an optional source is unavailable.
+
+Compaction preserves a stable structure: goal, constraints, completed and active work, blockers, decisions, next steps, critical facts/open questions, and relevant files. Durable references remain outside the prose summary and can be reloaded when needed.
+
+## Planning and Goal Execution
+
+### Plan authority
+
+A structural plan is a versioned DocumentStore document. Session hot state keeps only its plan pointer/version and stale latch. The model plan tool, human plan editor, Goal worker, UI, Grader, and archive all read and update the same plan through version-aware writes.
+
+The runtime derives plan staleness from facts it already observes:
+
+- the user adds new guidance;
+- a tool or execution step fails;
+- validation fails;
+- repeated work makes no progress;
+- the active domain-pack snapshot changes.
+
+Read and diagnosis tools remain available while stale. Mutating tools require the plan to be synchronized, with bounded anti-deadlock behavior.
+
+### Goal Loop
+
+A Goal has objective completion criteria, a plan, a durable run context, a budget ledger, and a bounded controller. Each tick:
+
+1. claims the expected durable Goal/plan version;
+2. applies pending user plan edits and steering;
+3. executes one coherent step;
+4. records tools, tokens, cost, time, evidence, and progress;
+5. evaluates objective criteria and stall state;
+6. emits facts and schedules the next tick only when eligible.
+
+`goal.tick.requested` is a durable command, not a post-hoc trace marker. Duplicate delivery cannot repeat provider or tool side effects. Pause, stop, takeover, hard limits, quiet hours, needs-human, and terminal state all stop self-continuation.
+
+Hot plan edits preserve reusable step IDs and completed evidence, increment the plan version, and reset progress/stall baselines without interrupting the in-flight tick.
+
+## Event-Driven Agent Runtime
+
+The Event Bus provides persist-before-dispatch delivery, idempotency, priority, retry, acknowledgement, dead-letter handling, retention, replay, and correlation. Local deployments use the embedded backend; distributed deployments use Redis Streams or Kafka through the same backend contract.
+
+The Router combines event type, trusted source, actor identity, Agent trigger/capability metadata, autonomy ceiling, approval intent, context strategy, deduplication, priority, and workspace backpressure into one traceable route decision.
+
+The Worker Pool owns bounded concurrency, placement, claims, leases, renewal, recovery, and handoff. Independent DAG nodes run concurrently; dependency edges remain ordered. File and symbol claims are shared across Workers so conflicting writes cannot run together.
+
+Write-capable Agents use isolated worktrees by default. Parent Agents receive bounded summaries, status, artifact/session references, and necessary diffs; complete child transcripts remain in their own Sessions.
+
+## Human Collaboration and Oversight
+
+### Repo & Wiki
+
+Repo & Wiki is a human-facing projection, never a second source of truth. It exposes document and code navigation, full-text search, docs-to-code links, knowledge governance, and execution archives. Organization and workspace identity are enforced on query, index, archive, and promotion paths.
+
+### Expert Panel
+
+Panelists receive the same frozen question and evidence under differentiated lenses. Anonymous multi-round debate avoids identity anchoring. A deterministic Arbiter applies quorum, preserves minority opinions, and routes unsafe ambiguity to the Approval Queue. Distributed panelists run through the Worker Pool.
+
+### IM and proactive delivery
+
+Project IM supports groups, direct conversations, threads, search, attachments, agent mentions, progress streaming, and permission revalidation when project bindings change. Event-driven notifications and digests pass through content safety, path ACL, external-link, rate, and quiet-hours policies before delivery.
+
+### Oversight
+
+Correlation IDs connect event, route, context, worker claim, Session, provider turn, tool, artifact, approval, and outbound action. Operators can inspect Approval Queue items, dead letters, budgets, conflicts, takeovers, rollbacks, and final delivery without reconstructing state from logs.
+
+## Learning and Knowledge Governance
+
+Learning runs outside the interactive turn on idle, pause, project switch, and Session finalization triggers. Candidates enter the same DocumentStore lifecycle used by human governance.
+
+- Low-risk project candidates can pass deterministic auto-review.
+- Medium-risk/global candidates use an isolated blank-thread reviewer with no Session history.
+- Sensitive, strategic, regulated, or irreversible candidates require human review.
+- Rejection status, reason, and fingerprint remain authoritative in DocumentStore; auxiliary indexes are rebuildable projections.
+- Promotion changes the same document's status/version instead of copying it into a second identity.
+- Released retrieval sets name a snapshot and carry evaluation matrix, baseline, repeats, and ablation verdict.
+
+A failed release gate restores the previous knowledge snapshot. Selected and rejected refs remain reproducible in run artifacts.
+
+## Code Intelligence
+
+The AI IDE surface combines:
-Supported intents: `definition · references · implementations · type · calls_in · calls_out · supertypes · subtypes · type_hints · hover · rename_preview · quick_fix · outline · diagnostics · overview`
+- semantic symbol lookup and intent-oriented `code_intel` queries;
+- definitions, references, calls, imports, type hierarchy, diagnostics, and rename preview;
+- unsaved-buffer `textDocument/didOpen`, `didChange`, `didSave`, and `didClose` synchronization;
+- incremental code indexing with exact mtime I/O hints and content-SHA correctness authority;
+- deterministic fallback to repository search/read when a language server is unavailable.
-Graceful degradation: if no LSP server is configured for the file type, returns a hint to use `grep/read`. Capability only grows, never drops.
+Full LSP output stays in evidence artifacts. The model receives bounded summaries and precise source references.
----
+## MCP and Credential Security
-## MCP Catalog — Safety Model
+MCP servers can be added from the curated catalog or configured manually through Desktop, HTTP, or CLI. Catalog risk tiers are derived from trusted templates rather than mutable user configuration. Servers default to disconnected; writes, external fetches, and privileged actions pass through runtime permission gates.
-Each catalog entry carries a **risk tier** derived at load time from the catalog template. The tier is **not user-writable** — it is computed from the entry definition, preventing config-injection attacks.
+Credentials use indirection rather than plaintext project configuration:
-| Tier | Examples | Default behavior |
-|------|----------|-----------------|
-| `read_only` | postgres-readonly | All ops auto-allowed |
-| `write_guarded` | filesystem, github, git | Write ops require explicit approval |
-| `external_fetch` | fetch, browser (Playwright) | External requests require explicit approval |
+- `${VAR}` and `${VAR:-default}` resolve at connection time;
+- `secret://` handles resolve through macOS Keychain, Linux Secret Service, or Windows Credential Manager/DPAPI;
+- environments without a native keyring require explicit approval for an audited local fallback outside the project repository;
+- logs, artifacts, outbound messages, and config views redact resolved values.
-**Credentials** are declared by key name in the catalog template (`CredentialSpec`). Values are filled at enable-time.
+## Security Boundaries
-> **Known limitation (V3.4):** credential values are stored in plaintext in the local config file. Do not commit config files containing credentials to version control. A secure-storage mechanism (OS keyring, aligned with the codex approach) is planned for V3.5.
+Autonomous execution passes four independent gates:
----
+1. the event source is trusted for the workspace;
+2. the actor has permission for the project and resource;
+3. the Agent descriptor permits the trigger, capability, and autonomy level;
+4. the runtime permits the concrete tool, path, network target, and side effect.
-## Security Model Summary
+Additional controls include durable budgets, rate limits, quiet hours, secret/path/link filtering, worktree isolation, hardened read-only Git, human approval, takeover, rollback, and complete audit correlation.
-| Mechanism | Status |
-|-----------|--------|
-| MCP risk tier — catalog-derived, not config-injectable | ✅ V3.4 |
-| MCP catalog defaults to not connected | ✅ V3.4 |
-| Dangerous writes: approval gate (`ctx.ask`) | ✅ V3.4 |
-| Read-only DB: restricted-mode enforced at server | ✅ V3.4 |
-| Credential secure storage (OS keyring) | ⏳ V3.5 M-CRED |
+## Repository Map
----
+| Area | Path |
+|---|---|
+| Core Session, documents, context, event, and policy algebra | `packages/core/src/` |
+| CLI/server runtime, tools, Goal and event wiring | `packages/deepagent-code/src/` |
+| Desktop/Web application UI | `packages/app/src/` |
+| Electron host and isolated browser views | `packages/desktop/src/` |
+| Provider abstraction | `packages/llm/` |
+| Domain knowledge packs | `packages/domain-packs/` |
+| Generated JavaScript SDK | `packages/sdk/js/` |
-## License
+## License and Source
-DeepAgent Code is licensed under **AGPL-3.0-or-later**.
-Source code: [github.com/lessweb/deepagent-code](https://github.com/lessweb/deepagent-code)
+DeepAgent Code is licensed under **AGPL-3.0-or-later**. The canonical repository is [github.com/deepagent-ltd/deepagent-code](https://github.com/deepagent-ltd/deepagent-code).
-DeepAgent Code is derived from [opencode](https://github.com/sst/opencode) (MIT).
-See `NOTICE` in the repository root for the full upstream attribution.
+The project is derived from [opencode](https://github.com/sst/opencode) under the MIT License. Upstream attribution is preserved in [NOTICE](../NOTICE).
diff --git a/packages/app/package.json b/packages/app/package.json
index 9435d21f..466955fe 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@deepagent-code/app",
- "version": "1.4.0",
+ "version": "1.4.1",
"description": "",
"type": "module",
"exports": {
diff --git a/packages/app/src/components/dialog-custom-provider-form.ts b/packages/app/src/components/dialog-custom-provider-form.ts
index 1294b436..c94ec521 100644
--- a/packages/app/src/components/dialog-custom-provider-form.ts
+++ b/packages/app/src/components/dialog-custom-provider-form.ts
@@ -1,5 +1,33 @@
+import { isOfficialProvider } from "@deepagent-code/core/provider-official"
+
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
+const ANTHROPIC = "@ai-sdk/anthropic"
+
+export type ProviderProtocol = "openai-compatible" | "anthropic"
+
+// The config payload written under `provider.`. `discovery` and `models` are mutually exclusive
+// in practice (discovery mode emits an empty models map), but both are typed optional so the emitted
+// object has one consistent shape instead of a union callers must narrow.
+export type CustomProviderConfig = {
+ npm: string
+ name: string
+ env?: string[]
+ options: {
+ baseURL: string
+ apiKey?: string
+ headers?: Record
+ }
+ discovery?: boolean
+ models: Record
+}
+
+const npmForProtocol = (kind: ProviderProtocol | undefined) => (kind === "anthropic" ? ANTHROPIC : OPENAI_COMPATIBLE)
+
+// 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.
+const GENERIC_HOST_LABELS = new Set(["api", "www", "app", "gateway", "proxy", "open"])
type Translator = (key: string, vars?: Record) => string
@@ -46,29 +74,108 @@ type ValidateArgs = {
t: Translator
disabledProviders: string[]
existingProviderIDs: Set
+ // Protocol detected during model discovery; decides the SDK npm written to config. Defaults to
+ // openai-compatible when omitted (backward compatible with the manual form).
+ protocol?: ProviderProtocol
+ // Runtime discovery mode: when true AND the user listed no manual models, persist `discovery: true`
+ // with an empty model list so the backend refreshes models from the provider's /models endpoint on
+ // every load instead of freezing them into config. Manual models always take precedence and turn
+ // this off for that provider.
+ discovery?: boolean
+}
+
+// Turn a base URL into a stable, unique provider id + a human display name so the user only has to
+// enter URL + key. Rules:
+// - id is derived from the registrable host label (api.deepseek.com -> "deepseek",
+// open.bigmodel.cn -> "bigmodel"), slugified to satisfy PROVIDER_ID.
+// - reserved official ids (openai/deepseek/anthropic/zhipuai/xai/google/...) and any id already in
+// use are avoided by appending a numeric suffix, since a third-party id that collides with an
+// official one is rejected by the backend (THIRD_PARTY_PROVIDER_CONFLICT).
+// - `disabledProviders` do NOT count as taken: re-adding a previously disabled provider should be
+// able to reuse its id.
+export function deriveProviderIdentity(input: {
+ baseURL: string
+ existingProviderIDs: Set
+ disabledProviders?: string[]
+}): { providerID: string; name: string } {
+ const disabled = new Set(input.disabledProviders ?? [])
+ const taken = (id: string) =>
+ (input.existingProviderIDs.has(id) && !disabled.has(id)) || (isOfficialProvider(id) && !disabled.has(id))
+
+ const base = baseSlug(input.baseURL)
+ let providerID = base
+ let n = 2
+ while (taken(providerID)) {
+ providerID = `${base}-${n}`
+ n++
+ }
+ return { providerID, name: displayName(base) }
+}
+
+function baseSlug(baseURL: string): string {
+ let host = ""
+ try {
+ host = new URL(baseURL.trim()).hostname
+ } catch {
+ host = ""
+ }
+ const labels = host.split(".").filter(Boolean)
+ // Drop leading generic service labels (api., www., ...) so we land on the brand label.
+ while (labels.length > 1 && GENERIC_HOST_LABELS.has(labels[0].toLowerCase())) labels.shift()
+ // Prefer the registrable label: for a.b.com pick "b"; for single-label/localhost keep as-is.
+ const label = labels.length >= 2 ? labels[labels.length - 2] : (labels[0] ?? "")
+ const slug = label
+ .toLowerCase()
+ .replace(/[^a-z0-9-_]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ // Must satisfy PROVIDER_ID (starts alphanumeric). Fall back to a safe default.
+ return slug && PROVIDER_ID.test(slug) ? slug : "custom-provider"
+}
+
+function displayName(slug: string): string {
+ const cleaned = slug.replace(/[-_]+/g, " ").trim()
+ if (!cleaned) return "Custom Provider"
+ return cleaned
+ .split(" ")
+ .map((word) => (word ? word[0].toUpperCase() + word.slice(1) : word))
+ .join(" ")
}
export function validateCustomProvider(input: ValidateArgs) {
- const providerID = input.form.providerID.trim()
- const name = input.form.name.trim()
+ const typedID = input.form.providerID.trim()
+ const typedName = input.form.name.trim()
const baseURL = input.form.baseURL.trim()
const apiKey = input.form.apiKey.trim()
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
const key = apiKey && !env ? apiKey : undefined
+ const urlError = !baseURL
+ ? input.t("provider.custom.error.baseURL.required")
+ : !/^https?:\/\//.test(baseURL)
+ ? input.t("provider.custom.error.baseURL.format")
+ : undefined
+
+ // 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 providerID = typedID || derived?.providerID || ""
+ const name = typedName || derived?.name || ""
+
+ // Only the user's explicitly-typed id is format-checked; a derived id is always valid by
+ // construction. A blank id with no derivable URL still surfaces as "required".
const idError = !providerID
? input.t("provider.custom.error.providerID.required")
- : !PROVIDER_ID.test(providerID)
+ : typedID && !PROVIDER_ID.test(typedID)
? input.t("provider.custom.error.providerID.format")
: undefined
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
- const urlError = !baseURL
- ? input.t("provider.custom.error.baseURL.required")
- : !/^https?:\/\//.test(baseURL)
- ? input.t("provider.custom.error.baseURL.format")
- : undefined
const disabled = input.disabledProviders.includes(providerID)
const existsError = idError
@@ -77,6 +184,11 @@ export function validateCustomProvider(input: ValidateArgs) {
? input.t("provider.custom.error.providerID.exists")
: undefined
+ // Discovery mode is only active when the user listed no manual models: the model list then comes
+ // from the backend at runtime, so the empty model rows must not fail validation.
+ const hasManualModels = input.form.models.some((m) => m.id.trim().length > 0)
+ const discoveryMode = !!input.discovery && !hasManualModels
+
const seenModels = new Set()
const models = input.form.models.map((m) => {
const id = m.id.trim()
@@ -91,7 +203,7 @@ export function validateCustomProvider(input: ValidateArgs) {
const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
return { id: idError, name: nameError }
})
- const modelsValid = models.every((m) => !m.id && !m.name)
+ const modelsValid = discoveryMode || models.every((m) => !m.id && !m.name)
const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
const seenHeaders = new Set()
@@ -128,26 +240,25 @@ export function validateCustomProvider(input: ValidateArgs) {
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
if (!ok) return { err, models, headers }
+ const config: CustomProviderConfig = {
+ npm: npmForProtocol(input.protocol),
+ name,
+ ...(env ? { env: [env] } : {}),
+ options: {
+ baseURL,
+ ...(key ? { apiKey: key } : {}),
+ ...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}),
+ },
+ // Discovery mode: persist the opt-in flag and an empty model list (backend refreshes at runtime).
+ // Manual mode: freeze the listed models and leave discovery off.
+ ...(discoveryMode ? { discovery: true, models: {} } : { models: modelConfig }),
+ }
+
return {
err,
models,
headers,
- result: {
- providerID,
- name,
- key,
- config: {
- npm: OPENAI_COMPATIBLE,
- name,
- ...(env ? { env: [env] } : {}),
- options: {
- baseURL,
- ...(key ? { apiKey: key } : {}),
- ...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}),
- },
- models: modelConfig,
- },
- },
+ result: { providerID, name, key, config },
}
}
diff --git a/packages/app/src/components/dialog-custom-provider.test.ts b/packages/app/src/components/dialog-custom-provider.test.ts
index 07dd26ec..15c8689f 100644
--- a/packages/app/src/components/dialog-custom-provider.test.ts
+++ b/packages/app/src/components/dialog-custom-provider.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
-import { validateCustomProvider } from "./dialog-custom-provider-form"
+import { deriveProviderIdentity, validateCustomProvider } from "./dialog-custom-provider-form"
const t = (key: string) => key
@@ -77,4 +77,175 @@ describe("validateCustomProvider", () => {
value: undefined,
})
})
+
+ test("derives provider id and name from the URL when left blank", () => {
+ const result = validateCustomProvider({
+ form: {
+ providerID: "",
+ name: "",
+ baseURL: "https://api.moonshot.cn/v1",
+ apiKey: "secret",
+ // No manual models: discovery would fill these; validation must not require them here since
+ // the dialog auto-fills discovered models before calling validate. Simulate a filled row.
+ models: [{ row: "m0", id: "kimi", name: "Kimi", err: {} }],
+ headers: [{ row: "h0", key: "", value: "", err: {} }],
+ err: {},
+ },
+ t,
+ disabledProviders: [],
+ existingProviderIDs: new Set(),
+ })
+
+ expect(result.err.providerID).toBeUndefined()
+ expect(result.err.name).toBeUndefined()
+ expect(result.result?.providerID).toBe("moonshot")
+ expect(result.result?.name).toBe("Moonshot")
+ })
+
+ test("persists anthropic npm when protocol is anthropic", () => {
+ const result = validateCustomProvider({
+ form: {
+ providerID: "claude-relay",
+ name: "Claude Relay",
+ baseURL: "https://relay.example.com",
+ apiKey: "secret",
+ models: [{ row: "m0", id: "claude-x", name: "Claude X", err: {} }],
+ headers: [{ row: "h0", key: "", value: "", err: {} }],
+ err: {},
+ },
+ t,
+ disabledProviders: [],
+ existingProviderIDs: new Set(),
+ protocol: "anthropic",
+ })
+
+ expect(result.result?.config.npm).toBe("@ai-sdk/anthropic")
+ })
+
+ test("defaults to openai-compatible npm when protocol omitted", () => {
+ const result = validateCustomProvider({
+ form: {
+ providerID: "relay",
+ name: "Relay",
+ baseURL: "https://relay.example.com",
+ apiKey: "secret",
+ models: [{ row: "m0", id: "m", name: "M", err: {} }],
+ headers: [{ row: "h0", key: "", value: "", err: {} }],
+ err: {},
+ },
+ t,
+ disabledProviders: [],
+ existingProviderIDs: new Set(),
+ })
+
+ expect(result.result?.config.npm).toBe("@ai-sdk/openai-compatible")
+ })
+
+ test("discovery mode persists discovery flag and empty models when no manual models", () => {
+ const result = validateCustomProvider({
+ form: {
+ providerID: "",
+ name: "",
+ baseURL: "https://api.moonshot.cn/v1",
+ apiKey: "secret",
+ // No manual models: the backend owns the list at runtime.
+ models: [{ row: "m0", id: "", name: "", err: {} }],
+ headers: [{ row: "h0", key: "", value: "", err: {} }],
+ err: {},
+ },
+ t,
+ disabledProviders: [],
+ existingProviderIDs: new Set(),
+ discovery: true,
+ })
+
+ expect(result.err.providerID).toBeUndefined()
+ expect(result.result?.providerID).toBe("moonshot")
+ expect(result.result?.config.discovery).toBe(true)
+ expect(result.result?.config.models).toEqual({})
+ })
+
+ test("explicit anthropic protocol choice persists the anthropic npm even without detection", () => {
+ // Simulates the user picking Anthropic in the protocol selector: the dialog passes protocol
+ // "anthropic" to the form, which must win regardless of any auto-detection.
+ const result = validateCustomProvider({
+ form: {
+ providerID: "relay",
+ name: "Relay",
+ baseURL: "https://relay.example.com",
+ apiKey: "secret",
+ models: [{ row: "m0", id: "m", name: "M", err: {} }],
+ headers: [{ row: "h0", key: "", value: "", err: {} }],
+ err: {},
+ },
+ t,
+ disabledProviders: [],
+ existingProviderIDs: new Set(),
+ protocol: "anthropic",
+ })
+
+ expect(result.result?.config.npm).toBe("@ai-sdk/anthropic")
+ })
+
+ test("manual models override discovery mode: freeze models, no discovery flag", () => {
+ const result = validateCustomProvider({
+ form: {
+ providerID: "relay",
+ name: "Relay",
+ baseURL: "https://relay.example.com",
+ apiKey: "secret",
+ models: [{ row: "m0", id: "custom-model", name: "Custom Model", err: {} }],
+ headers: [{ row: "h0", key: "", value: "", err: {} }],
+ err: {},
+ },
+ t,
+ disabledProviders: [],
+ existingProviderIDs: new Set(),
+ // Even with discovery requested, a hand-listed model wins and turns discovery off.
+ discovery: true,
+ })
+
+ expect(result.result?.config.discovery).toBeUndefined()
+ expect(result.result?.config.models).toEqual({ "custom-model": { name: "Custom Model" } })
+ })
+})
+
+describe("deriveProviderIdentity", () => {
+ test("strips generic service labels and uses the brand label", () => {
+ expect(deriveProviderIdentity({ baseURL: "https://api.moonshot.cn/v1", existingProviderIDs: new Set() })).toEqual({
+ providerID: "moonshot",
+ name: "Moonshot",
+ })
+ expect(
+ deriveProviderIdentity({ baseURL: "https://open.bigmodel.cn/api/paas/v4", existingProviderIDs: new Set() }),
+ ).toEqual({ providerID: "bigmodel", name: "Bigmodel" })
+ })
+
+ test("avoids reserved official provider ids by suffixing", () => {
+ // openai is a reserved official id; a third-party endpoint must not claim it.
+ const result = deriveProviderIdentity({ baseURL: "https://api.openai.com/v1", existingProviderIDs: new Set() })
+ expect(result.providerID).toBe("openai-2")
+ })
+
+ test("avoids ids already in use", () => {
+ const result = deriveProviderIdentity({
+ baseURL: "https://api.moonshot.cn/v1",
+ existingProviderIDs: new Set(["moonshot", "moonshot-2"]),
+ })
+ expect(result.providerID).toBe("moonshot-3")
+ })
+
+ test("lets a disabled provider reclaim its id", () => {
+ const result = deriveProviderIdentity({
+ baseURL: "https://api.moonshot.cn/v1",
+ existingProviderIDs: new Set(["moonshot"]),
+ disabledProviders: ["moonshot"],
+ })
+ expect(result.providerID).toBe("moonshot")
+ })
+
+ test("falls back to a safe id for an unparseable URL", () => {
+ const result = deriveProviderIdentity({ baseURL: "not a url", existingProviderIDs: new Set() })
+ expect(result.providerID).toBe("custom-provider")
+ })
})
diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx
index 90dfb92e..574fc9e3 100644
--- a/packages/app/src/components/dialog-custom-provider.tsx
+++ b/packages/app/src/components/dialog-custom-provider.tsx
@@ -6,14 +6,29 @@ import { ProviderIcon } from "@deepagent-code/ui/provider-icon"
import { useMutation } from "@tanstack/solid-query"
import { TextField } from "@deepagent-code/ui/text-field"
import { showToast } from "@/utils/toast"
-import { batch, For } from "solid-js"
+import { batch, createSignal, For, Show } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
-import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
+import {
+ type FormState,
+ type ProviderProtocol,
+ deriveProviderIdentity,
+ headerRow,
+ modelRow,
+ validateCustomProvider,
+} from "./dialog-custom-provider-form"
import { DialogSelectProvider } from "./dialog-select-provider"
+import { Select } from "@deepagent-code/ui/select"
+
+// "auto" keeps the zero-config behavior (probe openai-compatible then anthropic and persist whichever
+// answered). The explicit choices let the user pin the protocol when auto-detection is wrong or the
+// endpoint doesn't implement /models. The choice decides both the discovery probe `kind` and the SDK
+// npm persisted to config.
+type ProtocolChoice = "auto" | ProviderProtocol
+const PROTOCOL_CHOICES: ProtocolChoice[] = ["auto", "openai-compatible", "anthropic"]
type Props = {
back?: "providers" | "close"
@@ -35,6 +50,20 @@ export function DialogCustomProvider(props: Props) {
err: {},
})
+ // Advanced section (provider id / display name / headers / manual models) is collapsed by default:
+ // the zero-config path only needs Base URL + API key. Protocol detected during discovery decides
+ // which SDK npm gets persisted.
+ const [showAdvanced, setShowAdvanced] = createSignal(false)
+ const [detectedProtocol, setDetectedProtocol] = createSignal(undefined)
+ // User's explicit protocol choice. "auto" (default) preserves auto-detection; an explicit choice
+ // pins both the discovery probe kind and the persisted npm.
+ const [protocolChoice, setProtocolChoice] = createSignal("auto")
+
+ // The protocol persisted to config: an explicit choice wins; otherwise the detected one (or
+ // openai-compatible default when nothing was detected).
+ const resolvedProtocol = (): ProviderProtocol | undefined =>
+ protocolChoice() === "auto" ? detectedProtocol() : (protocolChoice() as ProviderProtocol)
+
const goBack = () => {
if (props.back === "close") {
dialog.close()
@@ -109,15 +138,14 @@ export function DialogCustomProvider(props: Props) {
.map((h) => [h.key, h.value]),
)
- const discoveredRows = (models: Array<{ id: string; name: string }>) =>
- models.map((model) => ({ ...modelRow(), id: model.id, name: model.name || model.id }))
-
- const validate = (nextForm: FormState = form) => {
+ const validate = (nextForm: FormState = form, discovery = false) => {
const output = validateCustomProvider({
form: nextForm,
t: language.t,
disabledProviders: serverSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
+ protocol: resolvedProtocol(),
+ discovery,
})
batch(() => {
setForm("err", output.err)
@@ -159,39 +187,55 @@ export function DialogCustomProvider(props: Props) {
void (async () => {
let nextForm: FormState = form
- const providerID = form.providerID.trim()
const baseURL = form.baseURL.trim()
const key = form.apiKey.trim()
const env = key.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
// Whether the user already typed at least one model id by hand.
const hasManualModels = form.models.some((m) => m.id.trim().length > 0)
- if (providerID && baseURL && key && !env) {
+ // providerID is optional now (derived from URL when blank); discovery only needs URL + key.
+ // Pass a best-effort id so the backend has something to label the request with.
+ const discoverID = form.providerID.trim() || deriveProviderIdentity({
+ baseURL,
+ existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
+ disabledProviders: serverSync.data.config.disabled_providers ?? [],
+ }).providerID
+ // Runtime discovery mode: when the user provides only URL+key (no manual models) and the
+ // endpoint answers discovery, persist `discovery: true` with an empty model list so the backend
+ // refreshes models on every load instead of freezing this snapshot into config.
+ let discoveryMode = false
+ if (baseURL && key && !env) {
// Model discovery is a convenience, not a requirement: many OpenAI-compatible servers
// (some vLLM setups, local runtimes) don't implement GET /v1/models and return 400/404.
- // Treat discovery as best-effort — only use its result to auto-fill models when the user
- // hasn't entered any. If it fails (or returns nothing) we keep the user's manual models and
- // let validation handle the empty case, instead of blocking save on a missing endpoint.
- const discovered = await serverSDK.client.provider.models
+ // Treat discovery as best-effort — probe to detect the protocol (→ SDK npm) and confirm the
+ // endpoint works. If it fails we fall back to manual models and let validation handle the
+ // empty case instead of blocking save on a missing endpoint.
+ // Auto (kind omitted) lets the backend probe openai-compatible then anthropic and report which
+ // protocol answered. An explicit choice pins the probe to that protocol.
+ const choice = protocolChoice()
+ const res = await serverSDK.client.provider.models
.discover(
{
- providerID,
+ providerID: discoverID,
baseURL,
apiKey: key,
headers: headerConfig(),
- kind: "openai-compatible",
+ ...(choice === "auto" ? {} : { kind: choice }),
},
{ throwOnError: true },
)
- .then((res) => res.data?.models ?? [])
- .catch(() => [])
+ .then((res) => res.data)
+ .catch(() => undefined)
+ if (res?.kind) setDetectedProtocol(res.kind)
+ const discovered = res?.models ?? []
if (discovered.length > 0 && !hasManualModels) {
- const rows = discoveredRows(discovered)
- setForm("models", rows)
- nextForm = { ...form, models: rows }
+ // Backend owns the model list at runtime, so we leave the form's manual-model rows empty
+ // and persist `discovery: true` instead of freezing this snapshot. (nextForm stays the
+ // empty-models form, keeping the validator in discovery mode.)
+ discoveryMode = true
}
}
- const result = validate(nextForm)
+ const result = validate(nextForm, discoveryMode)
if (!result) return
saveMutation.mutate(result)
})().catch((err) => {
@@ -231,9 +275,55 @@ export function DialogCustomProvider(props: Props) {
setField("baseURL", v)}
+ validationState={form.err.baseURL ? "invalid" : undefined}
+ error={form.err.baseURL}
+ />
+ setField("apiKey", v)}
+ />
+
+
{language.t("provider.custom.field.protocol.label")}
+
o}
+ label={(o) => language.t(`provider.custom.field.protocol.option.${o}`)}
+ onSelect={(o) => o && setProtocolChoice(o)}
+ class="max-w-[220px] text-text-base"
+ variant="secondary"
+ />
+
+ {language.t("provider.custom.field.protocol.description")}
+
+
+
+
+ setShowAdvanced((v) => !v)}
+ class="self-start"
+ >
+ {language.t("provider.custom.advanced.toggle")}
+
+
+
+
+ setField("providerID", v)}
validationState={form.err.providerID ? "invalid" : undefined}
@@ -247,25 +337,11 @@ export function DialogCustomProvider(props: Props) {
validationState={form.err.name ? "invalid" : undefined}
error={form.err.name}
/>
- setField("baseURL", v)}
- validationState={form.err.baseURL ? "invalid" : undefined}
- error={form.err.baseURL}
- />
- setField("apiKey", v)}
- />
{language.t("provider.custom.models.label")}
+
{language.t("provider.custom.models.autoHint")}
{(m, i) => (
@@ -351,6 +427,7 @@ export function DialogCustomProvider(props: Props) {
{language.t("provider.custom.headers.add")}
+
{
// Shared gate predicate (C3): the handoff is available at high+ (mode !== "general"), same door as
// knowledge. The mode string is whatever the caller's AgentMode is.
export const shouldLoadBridge = (mode: string): boolean => mode !== "general" && mode !== "disabled"
+
+// ---------------------------------------------------------------------------------------------------
+// V4.0.1 P1 (§3.3) — World State persistence, project-scoped. World State reuses the SAME project-level
+// carrying capability the bridge already has (project-scoped durable store), but as STRUCTURED slots
+// (snapshot-diff over the latest value), NOT the forward-looking handoff text. Persisted as its own
+// `context_snapshot` doc (idSlug `world-state-`) so it is a NEW document that touches
+// neither the ledger nor the bridge schema (migration is purely additive, §3.5). The doc's version
+// chain is the World State snapshot history.
+//
+// GOAL-WORKER RECALL (P3(d)): `shouldLoadBridge("general")` is false, and the goal-worker's plan bridge
+// defaults to agentMode "general" — so the general knowledge/handoff short-circuit (bridge.ts:117) would
+// starve it. World State does NOT go through that gate: `loadWorldStateForGoalWorker` is a dedicated,
+// gate-free read the goal-loop wiring calls unconditionally (the flag `worldStateReinjection` is the only
+// gate). We deliberately do NOT flip the :117 predicate — that would leak the full knowledge handoff to
+// ALL general subagents; this targeted path reaches only the goal-worker with only its World State.
+// ---------------------------------------------------------------------------------------------------
+
+const WORLD_STATE_SLUG = "world-state"
+const worldStateProjectScope = (projectId: string): string => `durable:project:${projectId}`
+
+// Load the current project World State, or an empty one. Sync; Effect callers wrap with cause recovery.
+// Tolerant: a malformed/absent doc degrades to an empty World State (never throws).
+export const loadWorldState = (store: DocumentStore, projectId: string): WorldState => {
+ const scope = worldStateProjectScope(projectId)
+ for (const ref of store.list({ type: "context_snapshot", scope })) {
+ const doc = store.get(ref.id)
+ if (!doc || doc.extensions?.["snapshot_kind"] !== "world_state") continue
+ try {
+ const data = JSON.parse(doc.body) as { slots?: unknown }
+ const slots = Array.isArray(data.slots) ? (data.slots as WorldStateSlot[]) : []
+ return { projectId, slots }
+ } catch {
+ return emptyWorldState(projectId)
+ }
+ }
+ return emptyWorldState(projectId)
+}
+
+// Persist a World State by upserting the project-scoped `context_snapshot` doc. Idempotent: because the
+// body is the deterministic (KIND_ORDER-sorted) World State JSON and DocumentStore.upsert is a
+// content-addressed no-op when the body is unchanged (INV-4), a tick that changed no slot value bumps
+// NO version — which is exactly what keeps the re-injected tail byte-stable across ticks.
+export const persistWorldState = (store: DocumentStore, ws: WorldState): void => {
+ store.upsert({
+ type: "context_snapshot",
+ scope: worldStateProjectScope(ws.projectId),
+ idSlug: `${WORLD_STATE_SLUG}-${ws.projectId}`,
+ description: `world state ${ws.projectId}`,
+ body: JSON.stringify({ projectId: ws.projectId, slots: ws.slots }),
+ provenance: { source: "runner", run_ref: worldStateProjectScope(ws.projectId) },
+ extensions: { snapshot_kind: "world_state" },
+ })
+}
+
+// P3(d) — the gate-free goal-worker read. Identical to loadWorldState today; kept as a named seam so
+// the "always load World State for the goal-worker, bypassing shouldLoadBridge" intent is explicit at
+// the call site and cannot be accidentally re-gated behind the general short-circuit.
+export const loadWorldStateForGoalWorker = (store: DocumentStore, projectId: string): WorldState =>
+ loadWorldState(store, projectId)
diff --git a/packages/core/src/deepagent/context/index.ts b/packages/core/src/deepagent/context/index.ts
index fcfe37a4..887e1bd9 100644
--- a/packages/core/src/deepagent/context/index.ts
+++ b/packages/core/src/deepagent/context/index.ts
@@ -8,3 +8,5 @@ export * as ContextConfig from "./config"
export * as SessionLedger from "./ledger"
export * as ConversationLog from "./conversation-log"
export * as ProjectBridge from "./bridge"
+// V4.0.1 P1 — World State (snapshot-diff volatile facts, re-injected as a tail block, never the prefix).
+export * as WorldState from "./world-state"
diff --git a/packages/core/src/deepagent/context/world-state.ts b/packages/core/src/deepagent/context/world-state.ts
new file mode 100644
index 00000000..77d580ec
--- /dev/null
+++ b/packages/core/src/deepagent/context/world-state.ts
@@ -0,0 +1,115 @@
+import { Schema } from "effect"
+
+// V4.0.1 P1 (§3.3/§3.4) — the World State layer. Its ONE job is the responsibility separation the
+// compaction summary can NOT do well: the summary records "思路 + 待办" (progress / decisions /
+// constraints / next steps / data references), while World State records the VOLATILE structured facts
+// (open files, git, diagnostics, environment) as their LATEST value — re-injected as a TAIL user block
+// (never the byte-stable system prefix, per the cache-hit regression lesson). Because it is a
+// snapshot-diff over the LATEST value (覆盖式, not accumulative), a stale summary line about a file's
+// contents is always overwritten by the current World State value the model sees at the tail.
+//
+// snapshot-diff (§3.3): `upsertSlot` bumps a slot's `version` ONLY when the rendered value actually
+// changes. That is what keeps `renderWorldState` byte-stable across ticks when nothing changed — the
+// near-end tail stays cache-friendly, and only the one changed slot's segment moves when a value moves.
+
+export const WorldStateSlotKind = Schema.Literals([
+ "open_files", // key files' path + short summary (NOT full contents)
+ "vcs", // git branch / dirty / recent commit
+ "diagnostics", // most recent build/test/lint result summary
+ "env", // platform, tool versions, other key environment facts
+])
+export type WorldStateSlotKind = Schema.Schema.Type
+
+export const WorldStateSlot = Schema.Struct({
+ kind: WorldStateSlotKind,
+ version: Schema.Int, // snapshot-diff: +1 ONLY when the rendered value changes
+ updatedAt: Schema.Number,
+ value: Schema.String, // the rendered latest value (覆盖式, only the latest is kept)
+}).annotate({ identifier: "WorldStateSlot" })
+export type WorldStateSlot = Schema.Schema.Type
+
+export const WorldState = Schema.Struct({
+ projectId: Schema.String,
+ slots: Schema.Array(WorldStateSlot),
+}).annotate({ identifier: "WorldState" })
+export type WorldState = Schema.Schema.Type
+
+// A FIXED render/storage order so the tail block is deterministic (byte-stable) regardless of the order
+// slots were upserted in. Also the header label for each kind.
+const KIND_ORDER: readonly WorldStateSlotKind[] = ["open_files", "vcs", "diagnostics", "env"]
+const KIND_LABEL: Record = {
+ open_files: "Open Files",
+ vcs: "Version Control",
+ diagnostics: "Diagnostics",
+ env: "Environment",
+}
+
+const orderOf = (kind: WorldStateSlotKind): number => {
+ const i = KIND_ORDER.indexOf(kind)
+ return i < 0 ? KIND_ORDER.length : i
+}
+
+// Keep slots in KIND_ORDER so persistence + rendering are order-independent of upsert history.
+const sortSlots = (slots: readonly WorldStateSlot[]): WorldStateSlot[] =>
+ [...slots].sort((a, b) => orderOf(a.kind) - orderOf(b.kind))
+
+export const emptyWorldState = (projectId: string): WorldState => ({ projectId, slots: [] })
+
+// Overwrite-style update: replace the slot's value with the latest render, but bump `version` +
+// `updatedAt` ONLY when the value differs from what is already stored (snapshot-diff, §3.3). An
+// unchanged value returns a byte-identical slot (same version/updatedAt) so the rendered tail does not
+// churn the prompt cache. A brand-new slot starts at version 1.
+export const upsertSlot = (
+ ws: WorldState,
+ kind: WorldStateSlotKind,
+ value: string,
+ now: number = Date.now(),
+): WorldState => {
+ const existing = ws.slots.find((s) => s.kind === kind)
+ if (existing && existing.value === value) return ws // no change ⇒ no version bump, byte-stable
+ const nextSlot: WorldStateSlot = {
+ kind,
+ value,
+ version: existing ? existing.version + 1 : 1,
+ updatedAt: now,
+ }
+ const others = ws.slots.filter((s) => s.kind !== kind)
+ return { projectId: ws.projectId, slots: sortSlots([...others, nextSlot]) }
+}
+
+// Merge a batch of freshly-collected slot values into the World State (each entry snapshot-diffed via
+// upsertSlot). An omitted kind (undefined) or an empty string is a NON-collection this round — the
+// prior slot value is preserved (a tick that could not cheaply recompute diagnostics keeps the last
+// known diagnostics rather than blanking it). This is the pure merge; callers do the (bounded) IO to
+// obtain the rendered values, keeping this module free of collectors.
+export const collectSlots = (
+ ws: WorldState,
+ facts: Partial>,
+ now: number = Date.now(),
+): WorldState => {
+ let next = ws
+ for (const kind of KIND_ORDER) {
+ const value = facts[kind]
+ if (value == null || value.trim().length === 0) continue // non-collection ⇒ keep prior slot
+ next = upsertSlot(next, kind, value.trim(), now)
+ }
+ return next
+}
+
+// Render the World State as a TAIL user block fragment (never the static prefix). Deterministic slot
+// order + no version/timestamp in the output ⇒ byte-stable when the slot VALUES are unchanged (so a
+// multi-tick run with unchanged files keeps the near-end cache warm). Empty string when there is
+// nothing to inject (⇒ the caller skips injection entirely).
+export const renderWorldState = (ws: WorldState): string => {
+ const present = sortSlots(ws.slots).filter((s) => s.value.trim().length > 0)
+ if (present.length === 0) return ""
+ const sections = present.map((s) => `## ${KIND_LABEL[s.kind]}\n${s.value.trim()}`)
+ return [
+ "",
+ "Current environment / file / diagnostics facts (latest values, re-injected — trust these over any",
+ "older values mentioned in the summary above):",
+ "",
+ ...sections,
+ " ",
+ ].join("\n")
+}
diff --git a/packages/core/src/deepagent/goal-loop.ts b/packages/core/src/deepagent/goal-loop.ts
index 23b40590..71aa6f8b 100644
--- a/packages/core/src/deepagent/goal-loop.ts
+++ b/packages/core/src/deepagent/goal-loop.ts
@@ -59,12 +59,30 @@ export type CompletionCriterion = Schema.Schema.Type
export const GoalLimits = Schema.Struct({
maxTicks: Schema.Int,
+ // V4.0.1 P2 §4.3: maxTokens is NO LONGER a HALTING line. It is a required, bounded field (statistic /
+ // net-generation soft ceiling) but the gates below never route to needs_human on it — context pressure
+ // is handled by compaction (Wave 1), and halting authority lives ONLY on real external resources
+ // (maxWallclockMs / maxCost) + stall. validateSpec still requires it positive (boundedness invariant).
maxTokens: Schema.Int,
maxWallclockMs: Schema.Int,
maxCost: Schema.optional(Schema.Number),
+ // V4.0.1 P2 §4.4: tiered COST soft-notify fractions (0..1, matched descending). When ledger.cost/maxCost
+ // crosses the highest tier, a "converge, don't expand" reminder is threaded into the NEXT tick's step
+ // prompt tail (never the prefix) — a Codex-style nudge, NOT a stop line. Absent ⇒ the
+ // default [0.7, 0.9]. Only meaningful when maxCost > 0 (else the fraction is 0 and no tier is hit).
+ softNotifyFractions: Schema.optional(Schema.Array(Schema.Number)),
}).annotate({ identifier: "GoalLimits" })
export type GoalLimits = Schema.Schema.Type
+// V4.0.1 P2 §4.3 — the per-goal token ACCOUNTING convention, stamped at goal creation and persisted so a
+// restart never re-interprets an in-flight ledger. "gross" = the pre-V4.0.1 cumulative throughput (every
+// tick's full input, incl. the repeated static prefix, is summed). "net" = cumulative NET generation
+// (output+reasoning + the per-tick input DELTA over the carried prefix) so a long task's ledger does not
+// inflate linearly from the prefix. The marker (not the live flag) selects the accumulation in tick(), so
+// a goal created under "gross" stays gross even if the flag is later turned on (and vice versa).
+export const BudgetTokenScope = Schema.Literals(["gross", "net"])
+export type BudgetTokenScope = Schema.Schema.Type
+
export const GoalSpec = Schema.Struct({
planDocId: Schema.String, // 复用 plan DocType 作为 Goal 载体
criteria: Schema.Array(CompletionCriterion), // 全部满足才算完成(AND)
@@ -303,7 +321,26 @@ export const evaluateForController = (
/** Result of executing the active step. Feeds the Budget Ledger; `critical` triggers rollback. */
export type StepExecutorResult = {
+ /**
+ * GROSS throughput for the tick (input+output+reasoning). This is the pre-V4.0.1 accounting and the
+ * ONLY field consulted when the goal's budgetTokenScope is "gross" (byte-for-byte the legacy path).
+ */
readonly tokensUsed: number
+ /**
+ * V4.0.1 P2 §4.4 — the granular token breakdown used ONLY when budgetTokenScope is "net". The net
+ * ledger accrues `outputTokens + max(0, inputTokens − carriedPrefixTokens)`, so a long task's ledger
+ * no longer inflates linearly from the repeated static prefix each tick. All three are optional: when
+ * the scope is "net" but the executor did not surface them, the net path falls back to `tokensUsed`
+ * (still correct + monotonic, just without the prefix subtraction). See the wiring for the exact figures.
+ * - inputTokens : the tick's FULL input the model billed (prefix + new context), pre cache-adjust.
+ * - outputTokens : generated tokens (output + reasoning) — always net, never a repeated prefix.
+ * - carriedPrefixTokens: the best cheaply-available STABLE-PREFIX figure for the tick (the repeated
+ * system/tools/plan-seed prefix). Production uses the provider-reported cached
+ * prefix (cache.read+cache.write); absent ⇒ 0 (⇒ full input delta counted).
+ */
+ readonly inputTokens?: number
+ readonly outputTokens?: number
+ readonly carriedPrefixTokens?: number
readonly cost?: number
/** A critical / unrecoverable failure — the tick rolls back rather than continuing. */
readonly critical?: boolean
@@ -326,6 +363,14 @@ export type StepExecutor = (input: {
readonly sessionId: string
readonly planDocId: string
readonly activeStepId: string | null
+ /**
+ * V4.0.1 P2 §4.4 — the goal's budget so far (accumulated through PRIOR ticks; this tick's spend is not
+ * yet folded in) + the goal's limits, so the wiring can thread a tiered cost soft-notice into this
+ * tick's step-prompt TAIL (`budgetNotice`, gated by `goalBudgetSoftNotify`). The core loop supplies
+ * these; a stub executor may ignore them.
+ */
+ readonly ledger: BudgetLedger
+ readonly limits: GoalLimits
}) => Effect.Effect
/**
@@ -352,6 +397,13 @@ export type ControllerDeps = {
readonly executor: StepExecutor
readonly rollback: RollbackPort
readonly now: () => number
+ /**
+ * V4.0.1 P2 §4.5 — the `goalNetTokenBudget` flag (default OFF). Consulted ONCE, at goal creation, to
+ * STAMP the goal's `budgetTokenScope` marker ("net" when on, "gross" otherwise). tick() then reads the
+ * persisted marker — never this flag — so flipping the flag mid-flight never re-interprets an existing
+ * ledger. Absent ⇒ treated as OFF (gross), i.e. byte-for-byte the pre-V4.0.1 accumulation.
+ */
+ readonly netTokenBudget?: boolean
}
// The full persisted runtime state. Serialized to the run_context state doc body as JSON.
@@ -370,6 +422,11 @@ type GoalRuntimeState = {
readonly lastProcessedVersion: number | null
readonly lastOutcome: TickOutcome | null
readonly gaps: readonly string[]
+ // V4.0.1 P2 §4.5 — the token ACCOUNTING convention stamped at goal creation. Persisted so a restart (or
+ // a mid-flight flag flip) never re-interprets an existing ledger: tick() picks the accumulation by THIS
+ // marker, not the live flag. A state written before this field existed reads as undefined and is
+ // backfilled to "gross" in loadState (byte-for-byte the pre-V4.0.1 cumulative-throughput path).
+ readonly budgetTokenScope: BudgetTokenScope
}
const stateSlug = (goalId: string): string => `goal-state-${goalId}`
@@ -438,7 +495,83 @@ const loadState = (deps: ControllerDeps, handle: GoalHandle): GoalRuntimeState |
// a state written before lastEvidenceCount existed reads as undefined; default it to 0 so the
// first post-upgrade tick treats any existing evidence as the baseline (never a spurious stall
// reset, never a crash on the arithmetic comparison).
- return { ...parsed, lastEvidenceCount: parsed.lastEvidenceCount ?? 0 }
+ // Also backfill budgetTokenScope (P2 §4.5): a ledger persisted before the field existed was
+ // accumulated under the gross convention, so default to "gross" — never re-interpret it as net.
+ return {
+ ...parsed,
+ lastEvidenceCount: parsed.lastEvidenceCount ?? 0,
+ budgetTokenScope: parsed.budgetTokenScope ?? "gross",
+ }
+ } catch {
+ return null
+ }
+ }
+ return null
+}
+
+// V4.1 cross-process cold recovery — a durable home for a PENDING USER PLAN EDIT so the event-driven
+// GoalTickConsumer (which may run on a cold fiber with no in-memory control map) can pick it up. The
+// in-process driver historically kept the pending PlanInput ONLY in the goal-manager `controls` map;
+// that is invisible to a cold consumer. We persist it as a `run_context` doc under the SAME store, but
+// DELIBERATELY WITHOUT the `goal_id` extension (which `loadState` matches on at line ~434) — instead we
+// mark it with `pending_edit_goal_id`, so `loadState` skips it (its goal_id is undefined ≠ the handle's)
+// and never tries to parse a PlanInput as GoalRuntimeState. An empty body is the "no pending edit"
+// sentinel (there is no doc delete API). The consumer reads it via its own fresh store handle; the
+// content-equality clear in markPlanEditConsumed preserves a newer edit written between read and clear.
+const pendingEditSlug = (goalId: string): string => `goal-pending-edit-${goalId}`
+
+export const persistPendingPlanEdit = (
+ store: DocumentStore,
+ sessionId: string,
+ goalId: string,
+ plan: PlanInput | null,
+): void => {
+ store.upsert({
+ type: "run_context",
+ scope: planScope(sessionId),
+ description: `goal pending plan edit ${goalId}`,
+ idSlug: pendingEditSlug(goalId),
+ body: plan == null ? "" : JSON.stringify(plan),
+ provenance: { source: "runner", run_ref: planScope(sessionId) },
+ // NOTE: pending_edit_goal_id, NOT goal_id — keeps loadState() from ever matching this doc.
+ extensions: { pending_edit_goal_id: goalId },
+ })
+}
+
+export const readPendingPlanEdit = (store: DocumentStore, sessionId: string, goalId: string): PlanInput | null => {
+ for (const ref of store.list({ type: "run_context", scope: planScope(sessionId) })) {
+ const doc = store.get(ref.id)
+ if (!doc) continue
+ if (doc.extensions?.pending_edit_goal_id !== goalId) continue
+ if (!doc.body.trim()) return null // sentinel: consumed / never set
+ try {
+ return JSON.parse(doc.body) as PlanInput
+ } catch {
+ return null
+ }
+ }
+ return null
+}
+
+// V4.1 §N — durable command cursor. Weight ticks by one more than the maximum continuing stall count so
+// resetting stallCount after progress cannot repeat or decrease the cursor. A replay advances stallCount;
+// an executed tick advances ledger.ticks; every continuing transition therefore produces a fresh key.
+export const readGoalTickCursor = (
+ store: DocumentStore,
+ sessionId: string,
+ goalId: string,
+): { readonly seq: number; readonly planVersion: number; readonly phase: GoalPhase } | null => {
+ for (const ref of store.list({ type: "run_context", scope: planScope(sessionId) })) {
+ const doc = store.get(ref.id)
+ if (!doc) continue
+ if (doc.extensions?.goal_id !== goalId) continue
+ try {
+ const state = JSON.parse(doc.body) as GoalRuntimeState
+ const stallThreshold =
+ state.spec?.stallThreshold && state.spec.stallThreshold > 0 ? state.spec.stallThreshold : 3
+ const seq = (state.ledger?.ticks ?? 0) * (stallThreshold + 1) + (state.stallCount ?? 0)
+ const planVersion = readPlan(store, state.planDocId).version
+ return { seq, planVersion, phase: state.phase }
} catch {
return null
}
@@ -515,9 +648,40 @@ const writeCompletionReport = (deps: ControllerDeps, state: GoalRuntimeState, pl
})
}
+// V4.0.1 P2 §4.4 — the DEFAULT tiered cost soft-notify fractions (ascending). Applied when a goal's
+// limits omit `softNotifyFractions`.
+export const DEFAULT_SOFT_NOTIFY_FRACTIONS: readonly number[] = [0.7, 0.9]
+
+/**
+ * V4.0.1 P2 §4.4 — the tiered COST soft-notify (a bypass of the halting gates: it NEVER stops the goal,
+ * it only produces a reminder string). Computes `fraction = cost/maxCost` (0 when maxCost is absent or
+ * non-positive), then returns the converge-reminder for the HIGHEST tier already crossed, else null. The
+ * caller (wiring `renderStepPrompt`) appends the result to the NEXT tick's step-prompt tail. Gated by the
+ * `goalBudgetSoftNotify` flag at the call site — this pure function itself is always available.
+ *
+ * Fractions are matched DESCENDING so that when several tiers are crossed the message reflects the
+ * highest one (e.g. at 95% with [0.7, 0.9] it reports 90%, not 70%). Non-finite / non-positive maxCost ⇒
+ * fraction 0 ⇒ null (a goal with no cost ceiling never soft-notifies on cost).
+ */
+export const budgetNotice = (ledger: BudgetLedger, limits: GoalLimits): string | null => {
+ const maxCost = limits.maxCost
+ const fraction = maxCost != null && Number.isFinite(maxCost) && maxCost > 0 ? ledger.cost / maxCost : 0
+ const tiers = [...(limits.softNotifyFractions ?? DEFAULT_SOFT_NOTIFY_FRACTIONS)].sort((a, b) => b - a)
+ for (const tier of tiers) {
+ if (fraction >= tier)
+ return `Budget ${Math.round(fraction * 100)}% used: prioritise completing the core steps and CONVERGE — do not expand scope.`
+ }
+ return null
+}
+
+// V4.0.1 P2 §4.3 — token count is NO LONGER a halting line. `overLimit` / `atOrOverLimit` keep the SAME
+// dual-gate structure and the SAME real-resource caps (ticks / wallclock / cost) that carry the
+// boundedness invariant, but they DELIBERATELY no longer compare ledger.tokens against maxTokens: context
+// pressure is absorbed by compaction (Wave 1), so a long unmonitored task never halts on an internal token
+// tally at 3am. maxTokens remains a bounded, required field (a net-generation statistic), just not a stop
+// line. Halting authority = maxTicks + maxWallclockMs + maxCost + stall.
const overLimit = (ledger: BudgetLedger, limits: GoalLimits): string | null => {
if (ledger.ticks > limits.maxTicks) return `maxTicks (${limits.maxTicks}) exceeded`
- if (ledger.tokens > limits.maxTokens) return `maxTokens (${limits.maxTokens}) exceeded`
if (ledger.wallclockMs > limits.maxWallclockMs) return `maxWallclockMs (${limits.maxWallclockMs}) exceeded`
if (limits.maxCost != null && ledger.cost > limits.maxCost) return `maxCost (${limits.maxCost}) exceeded`
return null
@@ -529,7 +693,7 @@ const overLimit = (ledger: BudgetLedger, limits: GoalLimits): string | null => {
// TRUE ceiling: it runs BEFORE the executor, so no unbounded turn is spent past the declared maximum.
const atOrOverLimit = (ledger: BudgetLedger, limits: GoalLimits): string | null => {
if (ledger.ticks >= limits.maxTicks) return `maxTicks (${limits.maxTicks}) reached`
- if (ledger.tokens >= limits.maxTokens) return `maxTokens (${limits.maxTokens}) reached`
+ // P2 §4.3: NO maxTokens pre-gate — token pressure is a compaction signal, never a halt (see overLimit).
if (ledger.wallclockMs >= limits.maxWallclockMs) return `maxWallclockMs (${limits.maxWallclockMs}) reached`
if (limits.maxCost != null && ledger.cost >= limits.maxCost) return `maxCost (${limits.maxCost}) reached`
return null
@@ -637,6 +801,9 @@ export const makeGoalLoop = (deps: ControllerDeps): GoalLoop => {
lastProcessedVersion: null,
lastOutcome: null,
gaps: [],
+ // P2 §4.5: stamp the accounting convention ONCE, from the flag, at creation. tick() reads this
+ // marker (never the live flag) so a later flag flip never re-interprets this goal's ledger.
+ budgetTokenScope: deps.netTokenBudget === true ? "net" : "gross",
}
persistState(deps, state)
return { goalId, planDocId: spec.planDocId, sessionId }
@@ -713,6 +880,10 @@ export const makeGoalLoop = (deps: ControllerDeps): GoalLoop => {
sessionId: state.sessionId,
planDocId: state.planDocId,
activeStepId: plan?.active_step_id ?? null,
+ // P2 §4.4: the budget-so-far (prior ticks) + limits, so the wiring can thread a tiered cost
+ // soft-notice into THIS tick's step-prompt tail (budgetNotice). Read-only for the executor.
+ ledger: state.ledger,
+ limits: state.spec.limits,
})
.pipe(Effect.catchCause(() => Effect.succeed({ tokensUsed: 0, critical: true } as StepExecutorResult)))
@@ -725,9 +896,28 @@ export const makeGoalLoop = (deps: ControllerDeps): GoalLoop => {
// Budget Ledger accumulation (injected clock for wallclock → deterministic + restart-exact).
// Guard BOTH tokens and cost against a non-finite port result: `Math.max(0, Math.trunc(NaN))`
- // is NaN, which would poison the ledger permanently and silently disable the token cap forever
- // (`NaN > maxTokens` is always false). Coerce a non-finite / negative token count to 0.
- const addTokens = Number.isFinite(execResult.tokensUsed) ? Math.max(0, Math.trunc(execResult.tokensUsed)) : 0
+ // is NaN, which would poison the ledger permanently. Coerce a non-finite / negative count to 0.
+ const trunc = (n: number | undefined): number => (Number.isFinite(n) ? Math.max(0, Math.trunc(n as number)) : 0)
+ // P2 §4.3/§4.5: the accumulation convention is chosen by the PERSISTED scope marker (never the live
+ // flag), so a restart / mid-flight flag flip is consistent.
+ // gross → the pre-V4.0.1 cumulative throughput (input+output+reasoning), byte-for-byte unchanged.
+ // net → output(+reasoning) + max(0, input − carriedPrefixTokens): the per-tick input DELTA over
+ // the repeated stable prefix, so the ledger no longer inflates linearly from the prefix.
+ // carriedPrefixTokens is the best cheaply-available stable-prefix figure (production: the
+ // provider-reported cached prefix); when the executor did not surface the granular
+ // breakdown the net path falls back to `tokensUsed` — still correct + monotonic, just
+ // without the subtraction. This is an APPROXIMATION: it counts the full input delta above
+ // the carried prefix (it does not attempt to distinguish new-context input from any
+ // uncached prefix churn), which is the intended net-generation soft ceiling.
+ let addTokens: number
+ if (state.budgetTokenScope === "net" && (execResult.outputTokens != null || execResult.inputTokens != null)) {
+ const out = trunc(execResult.outputTokens)
+ const inp = trunc(execResult.inputTokens)
+ const carried = trunc(execResult.carriedPrefixTokens)
+ addTokens = out + Math.max(0, inp - carried)
+ } else {
+ addTokens = trunc(execResult.tokensUsed)
+ }
// Symmetric with tokens: a negative cost from a misbehaving port would DECREMENT the ledger (and
// could indefinitely defer the maxCost cap), so clamp to 0 just like the token count.
const addCost = Number.isFinite(execResult.cost) ? Math.max(0, execResult.cost as number) : 0
diff --git a/packages/core/src/deepagent/lmn-events.ts b/packages/core/src/deepagent/lmn-events.ts
index c7e688d9..45fafb25 100644
--- a/packages/core/src/deepagent/lmn-events.ts
+++ b/packages/core/src/deepagent/lmn-events.ts
@@ -32,7 +32,21 @@ export const CI_FAILURE = "ci.failure"
export const PR_COMMENT = "pr.comment"
export const MONITOR_ALERT = "monitor.alert"
-// §N Goal Loop — the tick is now an event (durable/retryable/dedup'd); terminal states go to Oversight.
+// §N Goal Loop.
+//
+// COMMAND vs FACT (V4.0 §N event-driven execution). The contract says "tick = goal.tick 事件" with
+// persistence/retry/dedup/backpressure — i.e. tick must be DRIVEN BY consuming an event, not merely
+// observed. That requires two distinct kinds of event:
+// - GOAL_TICK_REQUESTED (COMMAND): "execute ONE tick of this goal". A consumer claims it, runs the
+// tick through the normal StepExecutor path, then — if non-terminal — re-emits the next requested
+// (the self-driving chain). Idempotent on plan-version (idempotencyKey = goal:tick::)
+// so a redelivered command never double-executes; nack → bus retry (real work is retried, not just
+// an observation). This is the event that carries the §N persistence/retry/dedup guarantee.
+// - GOAL_TICK (FACT / trace): a tick HAS happened — an after-the-fact observation for tracing. It is
+// `normal` priority and safely sheddable; it drives nothing. (Named `goal.tick` for back-compat; it
+// is the observed counterpart to the requested command, NOT a driver.)
+// Terminal facts (completed/needs_human/rolled_back) go to Oversight/Archive as before.
+export const GOAL_TICK_REQUESTED = "goal.tick.requested"
export const GOAL_TICK = "goal.tick"
export const GOAL_COMPLETED = "goal.completed"
export const GOAL_NEEDS_HUMAN = "goal.needs_human"
diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts
index 9f12caed..2cc1ce17 100644
--- a/packages/core/src/global.ts
+++ b/packages/core/src/global.ts
@@ -9,16 +9,23 @@ import { resolveDataPath, resolveHomeBase } from "./global-path"
const app = "deepagent-code"
const legacyData = path.join(xdgData!, app)
-const config = path.join(xdgConfig!, app)
+// Pre-unification global-config location (XDG config, e.g. ~/.config/deepagent-code). We no longer
+// treat this as the config root — user-editable config now lives at the data root (~/.deepagent/code),
+// alongside the db/auth/state, in the style of ~/.claude and ~/.codex. This constant is kept only so
+// migrateLegacyConfig() can relocate anything left behind here.
+const legacyConfig = path.join(xdgConfig!, app)
const tmp = path.join(os.tmpdir(), app)
// P2-F single storage-root source: delegate to the shared pure resolver (global-path.ts) so the
// control-plane resolver (deepagent/workspace.ts) computes the identical root for every env.
const homePath = () => resolveHomeBase(process.env)
const dataPath = () => resolveDataPath(process.env)
+// Config now shares the data root: user-editable config (config.jsonc, themes/, plugins/…) lives
+// directly under ~/.deepagent/code so everything DeepAgent owns is under one visible directory.
+const configPath = () => dataPath()
const cachePath = () => path.join(dataPath(), "cache")
const statePath = () => path.join(dataPath(), "state")
-const overrides: { log?: string } = {}
+const overrides: { log?: string; config?: string } = {}
const paths = {
get home() {
@@ -42,7 +49,12 @@ const paths = {
get cache() {
return cachePath()
},
- config,
+ get config() {
+ return overrides.config ?? configPath()
+ },
+ set config(value: string) {
+ overrides.config = value
+ },
get state() {
return statePath()
},
@@ -98,7 +110,29 @@ async function migrateLegacyData() {
)
}
+// Relocate the pre-unification global config (~/.config/deepagent-code) into the data root so
+// existing users keep their providers/plugins/themes. Non-destructive and idempotent: files that
+// already exist at the destination are never overwritten (force:false), and the legacy dir is left
+// in place — matching migrateLegacyData. The canonical rename (deepagent-code.jsonc -> config.jsonc)
+// is handled by the config layer once the file is at the data root.
+async function migrateLegacyConfig() {
+ if (path.resolve(legacyConfig) === path.resolve(Path.config)) return
+ const entries = await fs.readdir(legacyConfig, { withFileTypes: true }).catch(() => [])
+ if (entries.length === 0) return
+ await fs.mkdir(Path.config, { recursive: true })
+ await Promise.all(
+ entries.map((entry) =>
+ fs.cp(path.join(legacyConfig, entry.name), path.join(Path.config, entry.name), {
+ recursive: true,
+ force: false,
+ errorOnExist: false,
+ }),
+ ),
+ )
+}
+
await migrateLegacyData()
+await migrateLegacyConfig()
await Promise.all([
fs.mkdir(Path.data, { recursive: true }),
diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts
index 34418c3f..e0ba7a00 100644
--- a/packages/core/src/plugin/skill.ts
+++ b/packages/core/src/plugin/skill.ts
@@ -23,7 +23,7 @@ export const Plugin = PluginV2.define({
skill: new SkillV2.Info({
name: "customize-deepagent-code",
description:
- "Use ONLY when the user is editing or creating deepagent-code's own configuration: deepagent-code.json, deepagent-code.jsonc, files under .deepagent-code/, or files under ~/.config/deepagent-code/. Also use when creating or fixing deepagent-code agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring deepagent-code itself.",
+ "Use ONLY when the user is editing or creating deepagent-code's own configuration: config.jsonc, config.json, files under .deepagent-code/, or files under ~/.deepagent/code/. Also use when creating or fixing deepagent-code agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring deepagent-code itself.",
location: AbsolutePath.make("/builtin/customize-deepagent-code.md"),
content: CustomizeOpencodeContent,
}),
diff --git a/packages/core/src/plugin/skill/customize-deepagent-code.md b/packages/core/src/plugin/skill/customize-deepagent-code.md
index f081f7e6..313f1f33 100644
--- a/packages/core/src/plugin/skill/customize-deepagent-code.md
+++ b/packages/core/src/plugin/skill/customize-deepagent-code.md
@@ -40,11 +40,11 @@ already-loaded config until then.
| Scope | Path |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Project config | `./deepagent-code.json`, `./deepagent-code.jsonc`, or `.deepagent-code/deepagent-code.json` (deepagent-code walks up from the cwd to the worktree root) |
-| Global config | `~/.config/deepagent-code/deepagent-code.json` (NOT `~/.deepagent-code/`) |
+| Global config | `~/.deepagent/code/config.jsonc` (or `config.json`) |
| Project agents | `.deepagent-code/agent/.md` or `.deepagent-code/agents/.md` |
-| Global agents | `~/.config/deepagent-code/agent(s)/.md` |
+| Global agents | `~/.deepagent/code/agent(s)/.md` |
| Project skills | `.deepagent-code/skill(s)//SKILL.md` |
-| Global skills | `~/.config/deepagent-code/skill(s)//SKILL.md` |
+| Global skills | `~/.deepagent/code/skill(s)//SKILL.md` |
| External skills (auto-loaded) | `~/.claude/skills//SKILL.md`, `~/.agents/skills//SKILL.md` |
Configs from each scope are deep-merged. Project overrides global. Unknown
diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts
index f99848b4..f90334f6 100644
--- a/packages/core/src/session/compaction.ts
+++ b/packages/core/src/session/compaction.ts
@@ -50,6 +50,36 @@ Rules:
- Preserve exact file paths, commands, error strings, and identifiers when known.
- Do not mention the summary process or that context was compacted.`
+// V4.0.1 P1 (§3.4) — the NARROWED, four-bucket summary template. Responsibility separation: the summary
+// records ONLY 思路 + 待办 (progress+decisions / constraints+prefs / next steps / data references) and
+// explicitly does NOT record file contents / env values / diagnostics snapshots — those volatile facts
+// are re-injected at their LATEST value by the World State layer (never captured here, where they would
+// go stale). "Data References" keeps only the reference (path/identifier/link), never the content.
+// Gated by `worldStateReinjection` at the deepagent-code call site: with the flag OFF, buildPrompt uses
+// the legacy SUMMARY_TEMPLATE and NOTHING is re-injected (no information hole).
+const NARROW_SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside and keep the section order unchanged. Do not include the tags in your response.
+
+## Progress & Key Decisions
+- [what has been done + the key decisions made and WHY, or "(none)"]
+
+## Constraints & Preferences
+- [user constraints, preferences, specs, or "(none)"]
+
+## Next Steps
+- [ordered next actions, or "(none)"]
+
+## Data References
+- [only path / identifier / link + why it matters — NOT its contents, or "(none)"]
+
+
+Rules:
+- Summarize ONLY the four sections above: progress+decisions, constraints+preferences, next steps, data references.
+- Do NOT record file contents, environment values, or diagnostics snapshots — the system re-injects their latest values automatically. Recording them here would only go stale.
+- Under "Data References" record just the reference (path / identifier / link) and why it matters, never the content itself.
+- Keep every section, even when empty. Use terse bullets, not prose paragraphs.
+- Preserve exact file paths, commands, error strings, and identifiers when known.
+- Do not mention the summary process or that context was compacted.`
+
type Entry = {
readonly seq: number
readonly message: SessionMessage.Message
@@ -163,12 +193,18 @@ const select = (
}
}
-export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) =>
+export const buildPrompt = (input: {
+ readonly previousSummary?: string
+ readonly context: readonly string[]
+ // V4.0.1 P1 — when true, use the four-bucket NARROW template (no file/env/diagnostics snapshots; those
+ // are World-State re-injected). Default false ⇒ legacy SUMMARY_TEMPLATE (byte-for-byte pre-V4.0.1).
+ readonly narrow?: boolean
+}) =>
[
input.previousSummary
? `Update the anchored summary below using the conversation history above.\nPreserve still-true details, remove stale details, and merge in the new facts.\n\n${input.previousSummary}\n `
: "Create a new anchored summary from the conversation history.",
- SUMMARY_TEMPLATE,
+ input.narrow ? NARROW_SUMMARY_TEMPLATE : SUMMARY_TEMPLATE,
...input.context,
].join("\n\n")
diff --git a/packages/core/src/v1/config/provider.ts b/packages/core/src/v1/config/provider.ts
index d54a3f08..396abe78 100644
--- a/packages/core/src/v1/config/provider.ts
+++ b/packages/core/src/v1/config/provider.ts
@@ -81,6 +81,10 @@ export const Info = Schema.Struct({
npm: Schema.optional(Schema.String),
whitelist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
blacklist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
+ discovery: Schema.optional(Schema.Boolean).annotate({
+ description:
+ "Discover this provider's models at runtime from its /models endpoint instead of listing them in config. Discovered models are cached locally and refreshed periodically; manual `models` entries always take precedence.",
+ }),
options: Schema.optional(
Schema.StructWithRest(
Schema.Struct({
diff --git a/packages/core/test/deepagent/goal-loop.test.ts b/packages/core/test/deepagent/goal-loop.test.ts
index ece0cf0e..122a93aa 100644
--- a/packages/core/test/deepagent/goal-loop.test.ts
+++ b/packages/core/test/deepagent/goal-loop.test.ts
@@ -14,13 +14,20 @@ import {
} from "../../src/deepagent/plan-controller"
import {
makeGoalLoop,
+ readGoalTickCursor,
+ persistPendingPlanEdit,
+ readPendingPlanEdit,
evaluateForController,
+ budgetNotice,
InvalidGoalError,
type ControllerDeps,
type GraderPorts,
type StepExecutor,
+ type StepExecutorResult,
type RollbackPort,
type GoalSpec,
+ type BudgetLedger,
+ type GoalLimits,
type CompletionCriterion,
} from "../../src/deepagent/goal-loop"
@@ -286,6 +293,36 @@ describe("V3.9 §D — Controller tick semantics", () => {
expect(status.ledger.tokens).toBe(7)
})
+ test("goal tick cursor stays monotonic when progress follows a stalled tick", async () => {
+ const clock = new FakeClock()
+ const planDocId = putPlan([step("a", "pending")])
+ let executions = 0
+ const executor: StepExecutor = ({ planDocId }) =>
+ Effect.sync(() => {
+ executions++
+ updatePlan(planDocId, (plan) =>
+ executions === 1
+ ? { ...plan, goal: `${plan.goal}.` }
+ : {
+ ...plan,
+ steps: [{ ...plan.steps[0], evidence: [...(plan.steps[0].evidence ?? []), "made progress"] }],
+ },
+ )
+ return { tokensUsed: 1 }
+ })
+ const loop = makeGoalLoop(deps({ executor }, clock))
+ const handle = await Effect.runPromise(loop.start(spec(planDocId)))
+
+ expect(await Effect.runPromise(loop.tick(handle))).toBe("continue")
+ const stalled = readGoalTickCursor(store, SESSION, handle.goalId)!
+ expect(stalled.seq).toBe(5) // ticks=1, stall=1, threshold=3
+
+ expect(await Effect.runPromise(loop.tick(handle))).toBe("continue")
+ const progressed = readGoalTickCursor(store, SESSION, handle.goalId)!
+ expect(progressed.seq).toBe(8) // ticks=2, stall reset to 0
+ expect(progressed.seq).toBeGreaterThan(stalled.seq)
+ })
+
test("§D.6 无进展即停: stallThreshold consecutive no-progress ticks → needs_human", async () => {
const clock = new FakeClock()
const planDocId = putPlan([step("a", "pending")])
@@ -323,19 +360,28 @@ describe("V3.9 §D — Controller tick semantics", () => {
expect(await Effect.runPromise(loop.tick(handle))).toBe("needs_human")
})
- test("§D.6 有界性 (over-limit): exceeding maxTokens → needs_human", async () => {
+ test("V4.0.1 P2 §4.3: exceeding maxTokens does NOT halt (token is a compaction line, not a stop line)", async () => {
+ // Pre-V4.0.1 this exceeded the token cap and halted at needs_human. Under P2 the token count no longer
+ // drives a stop — context pressure is handled by compaction, halting only by wallclock/cost/stall. The
+ // tick makes forward progress (title touch is enough here since stall is high), so it must CONTINUE.
const clock = new FakeClock()
const planDocId = putPlan([step("a", "pending")])
const executor: StepExecutor = ({ planDocId }) =>
Effect.sync(() => {
- updatePlan(planDocId, (p) => ({ ...p, steps: [{ ...p.steps[0], title: `${p.steps[0].title}.` }] }))
+ updatePlan(planDocId, (p) => ({
+ ...p,
+ steps: [{ ...p.steps[0], evidence: [...(p.steps[0].evidence ?? []), "progress"] }],
+ }))
return { tokensUsed: 100 }
})
const loop = makeGoalLoop(deps({ executor }, clock))
const handle = await Effect.runPromise(
loop.start(spec(planDocId, { limits: { maxTicks: 99, maxTokens: 50, maxWallclockMs: 100_000 }, stallThreshold: 99 })),
)
- expect(await Effect.runPromise(loop.tick(handle))).toBe("needs_human")
+ expect(await Effect.runPromise(loop.tick(handle))).toBe("continue") // token cap no longer halts
+ const status = await Effect.runPromise(loop.status(handle))
+ expect(status.ledger.tokens).toBe(100) // ledger past maxTokens=50, yet running
+ expect(status.phase).toBe("running")
})
test("§D.6 有界性 (over-limit): exceeding maxWallclockMs → needs_human", async () => {
@@ -900,3 +946,302 @@ describe("V4.1 §S2 — goal plan hot-edit (applyPlanEdit)", () => {
expect(store.get(planDocId)!.version).toBe(v0)
})
})
+
+// V4.0.1 P2 — budget semantic refactor: token→compaction line (never a halt), tiered cost soft-notify,
+// net-generation ledger.
+describe("V4.0.1 P2 — budgetNotice (tiered cost soft-notify)", () => {
+ const ledgerAt = (cost: number): BudgetLedger => ({ ticks: 1, tokens: 0, cost, wallclockMs: 0, startedAtMs: 0 })
+ const limitsWith = (over: Partial = {}): GoalLimits => ({
+ maxTicks: 100,
+ maxTokens: 100_000,
+ maxWallclockMs: 100_000,
+ maxCost: 10,
+ ...over,
+ })
+
+ test("returns null below the lowest tier (<70%)", () => {
+ expect(budgetNotice(ledgerAt(6), limitsWith())).toBeNull() // 60%
+ })
+
+ test("returns the 70% notice at exactly 70%", () => {
+ const notice = budgetNotice(ledgerAt(7), limitsWith())
+ expect(notice).not.toBeNull()
+ expect(notice).toMatch(/70% used/)
+ expect(notice).toMatch(/CONVERGE/)
+ })
+
+ test("returns the 90% notice at exactly 90% (highest tier crossed wins)", () => {
+ expect(budgetNotice(ledgerAt(9), limitsWith())).toMatch(/90% used/)
+ })
+
+ test("crossing the highest tier still fires (95% → non-null, reports the ACTUAL usage percent)", () => {
+ // The tier gate (descending match) decides WHETHER to notify; the message always shows the actual
+ // usage fraction (95%), matching the design's Math.round(fr*100).
+ const notice = budgetNotice(ledgerAt(9.5), limitsWith())
+ expect(notice).not.toBeNull()
+ expect(notice).toMatch(/95% used/)
+ })
+
+ test("honours custom softNotifyFractions (descending match)", () => {
+ const limits = limitsWith({ softNotifyFractions: [0.5, 0.8] })
+ expect(budgetNotice(ledgerAt(4), limits)).toBeNull() // 40% < 50%
+ expect(budgetNotice(ledgerAt(6), limits)).toMatch(/60% used/) // crosses 50%, reports actual 60%
+ expect(budgetNotice(ledgerAt(8.5), limits)).toMatch(/85% used/) // crosses 80%, reports actual 85%
+ })
+
+ test("no cost ceiling (maxCost absent / 0) ⇒ never notifies (fraction 0)", () => {
+ expect(budgetNotice(ledgerAt(1000), limitsWith({ maxCost: undefined }))).toBeNull()
+ expect(budgetNotice(ledgerAt(1000), limitsWith({ maxCost: 0 }))).toBeNull()
+ })
+})
+
+describe("V4.0.1 P2 — token count is NOT a halting line (§4.3)", () => {
+ test("massive token pressure but wallclock/cost under limit → goal CONTINUES (never needs_human)", async () => {
+ const clock = new FakeClock()
+ const planDocId = putPlan([step("a", "pending")])
+ // Every tick burns tokens WAY past maxTokens, makes forward progress (new evidence), but stays under
+ // wallclock/cost. Pre-V4.0.1 this halted at needs_human on the token cap; now it must keep ticking.
+ const executor: StepExecutor = ({ planDocId }) =>
+ Effect.sync(() => {
+ updatePlan(planDocId, (p) => ({
+ ...p,
+ steps: [{ ...p.steps[0], evidence: [...(p.steps[0].evidence ?? []), `progress ${(p.steps[0].evidence?.length ?? 0) + 1}`] }],
+ }))
+ return { tokensUsed: 1_000_000 } // 1M tokens/tick, maxTokens is 50
+ })
+ const loop = makeGoalLoop(deps({ executor }, clock))
+ const handle = await Effect.runPromise(
+ loop.start(spec(planDocId, { limits: { maxTicks: 99, maxTokens: 50, maxWallclockMs: 100_000 }, stallThreshold: 99 })),
+ )
+ const outcomes: string[] = []
+ for (let i = 0; i < 5; i++) outcomes.push(await Effect.runPromise(loop.tick(handle)))
+ expect(outcomes).toEqual(["continue", "continue", "continue", "continue", "continue"]) // never halts on tokens
+ const status = await Effect.runPromise(loop.status(handle))
+ expect(status.phase).toBe("running")
+ expect(status.ledger.tokens).toBeGreaterThan(50) // ledger well past maxTokens, yet no halt
+ })
+
+ test("maxWallclockMs STILL halts (boundedness invariant intact)", async () => {
+ const clock = new FakeClock()
+ const planDocId = putPlan([step("a", "pending")])
+ const executor: StepExecutor = ({ planDocId }) =>
+ Effect.sync(() => {
+ updatePlan(planDocId, (p) => ({ ...p, steps: [{ ...p.steps[0], title: `${p.steps[0].title}.` }] }))
+ clock.advance(10_000)
+ return { tokensUsed: 1 }
+ })
+ const loop = makeGoalLoop(deps({ executor }, clock))
+ const handle = await Effect.runPromise(
+ loop.start(spec(planDocId, { limits: { maxTicks: 99, maxTokens: 1_000, maxWallclockMs: 5_000 }, stallThreshold: 99 })),
+ )
+ expect(await Effect.runPromise(loop.tick(handle))).toBe("needs_human")
+ })
+
+ test("maxCost STILL halts (boundedness invariant intact)", async () => {
+ const clock = new FakeClock()
+ const planDocId = putPlan([step("a", "pending")])
+ const executor: StepExecutor = ({ planDocId }) =>
+ Effect.sync(() => {
+ updatePlan(planDocId, (p) => ({ ...p, steps: [{ ...p.steps[0], title: `${p.steps[0].title}.` }] }))
+ return { tokensUsed: 1, cost: 100 }
+ })
+ const loop = makeGoalLoop(deps({ executor }, clock))
+ const handle = await Effect.runPromise(
+ loop.start(spec(planDocId, { limits: { maxTicks: 99, maxTokens: 1_000, maxWallclockMs: 100_000, maxCost: 10 }, stallThreshold: 99 })),
+ )
+ // Tick 1 spends cost 100 > maxCost 10 → the post-gate (overLimit) fires this same tick → needs_human.
+ expect(await Effect.runPromise(loop.tick(handle))).toBe("needs_human")
+ })
+
+ test("maxTicks STILL halts (boundedness invariant intact)", async () => {
+ const clock = new FakeClock()
+ const planDocId = putPlan([step("a", "pending")])
+ const executor: StepExecutor = ({ planDocId }) =>
+ Effect.sync(() => {
+ updatePlan(planDocId, (p) => ({ ...p, steps: [{ ...p.steps[0], title: `${p.steps[0].title}.` }] }))
+ return { tokensUsed: 1 }
+ })
+ const loop = makeGoalLoop(deps({ executor }, clock))
+ const handle = await Effect.runPromise(
+ loop.start(spec(planDocId, { limits: { maxTicks: 1, maxTokens: 1_000, maxWallclockMs: 100_000 }, stallThreshold: 99 })),
+ )
+ expect(await Effect.runPromise(loop.tick(handle))).toBe("continue")
+ expect(await Effect.runPromise(loop.tick(handle))).toBe("needs_human") // maxTicks ceiling
+ })
+
+ test("stall STILL halts (无进展即停 intact)", async () => {
+ const clock = new FakeClock()
+ const planDocId = putPlan([step("a", "pending")])
+ const executor: StepExecutor = ({ planDocId }) =>
+ Effect.sync(() => {
+ updatePlan(planDocId, (p) => ({ ...p, goal: `${p.goal}.` })) // version bump, no progress
+ return { tokensUsed: 1 }
+ })
+ const loop = makeGoalLoop(deps({ executor }, clock))
+ const handle = await Effect.runPromise(loop.start(spec(planDocId, { stallThreshold: 2 })))
+ expect(await Effect.runPromise(loop.tick(handle))).toBe("continue")
+ expect(await Effect.runPromise(loop.tick(handle))).toBe("needs_human")
+ })
+
+ test("validateSpec STILL rejects a non-positive maxTokens (bounded field, just not a halt line)", async () => {
+ const clock = new FakeClock()
+ const planDocId = putPlan([step("a", "pending")])
+ const loop = makeGoalLoop(deps({}, clock))
+ const err = await Effect.runPromise(
+ loop.start(spec(planDocId, { limits: { maxTicks: 1, maxTokens: 0, maxWallclockMs: 1 } })).pipe(Effect.flip),
+ )
+ expect(err).toBeInstanceOf(InvalidGoalError)
+ expect(err.reason).toMatch(/maxTokens/)
+ })
+})
+
+describe("V4.0.1 P2 — net-generation token accounting (§4.4/§4.5, budgetTokenScope marker)", () => {
+ // An executor that reports the granular breakdown of a tick whose FULL input re-pays a big fixed prefix
+ // every tick (the gross-inflation scenario). input=1000 (prefix 900 + 100 new), output=50.
+ const granularExecutor: StepExecutor = ({ planDocId }) =>
+ Effect.sync(() => {
+ updatePlan(planDocId, (p) => ({
+ ...p,
+ steps: [{ ...p.steps[0], evidence: [...(p.steps[0].evidence ?? []), `e${(p.steps[0].evidence?.length ?? 0) + 1}`] }],
+ }))
+ return {
+ tokensUsed: 1_050, // gross: input+output = 1000 + 50
+ inputTokens: 1_000,
+ outputTokens: 50,
+ carriedPrefixTokens: 900,
+ } satisfies StepExecutorResult
+ })
+
+ test("GROSS scope (flag OFF): ledger sums full throughput incl. the repeated prefix (legacy path)", async () => {
+ const clock = new FakeClock()
+ const planDocId = putPlan([step("a", "active")])
+ // netTokenBudget omitted (OFF) → scope "gross".
+ const loop = makeGoalLoop(deps({ executor: granularExecutor }, clock))
+ const handle = await Effect.runPromise(loop.start(spec(planDocId, { stallThreshold: 99 })))
+ for (let i = 0; i < 3; i++) await Effect.runPromise(loop.tick(handle))
+ const status = await Effect.runPromise(loop.status(handle))
+ // gross = 3 ticks × 1050 = 3150 (the prefix is re-counted every tick).
+ expect(status.ledger.tokens).toBe(3_150)
+ })
+
+ test("NET scope (flag ON): ledger does NOT inflate from the repeated prefix", async () => {
+ const clock = new FakeClock()
+ const planDocId = putPlan([step("a", "active")])
+ const loop = makeGoalLoop(deps({ executor: granularExecutor, netTokenBudget: true }, clock))
+ const handle = await Effect.runPromise(loop.start(spec(planDocId, { stallThreshold: 99 })))
+ for (let i = 0; i < 3; i++) await Effect.runPromise(loop.tick(handle))
+ const status = await Effect.runPromise(loop.status(handle))
+ // net per tick = output 50 + max(0, input 1000 − carried 900) = 50 + 100 = 150. 3 ticks → 450.
+ // This is far below the gross 3150 — the ledger no longer inflates linearly from the prefix.
+ expect(status.ledger.tokens).toBe(450)
+ })
+
+ test("NET scope falls back to gross tokensUsed when granular fields are absent (monotonic)", async () => {
+ const clock = new FakeClock()
+ const planDocId = putPlan([step("a", "active")])
+ // Executor reports only gross tokensUsed (no breakdown) even though scope is "net".
+ const executor: StepExecutor = ({ planDocId }) =>
+ Effect.sync(() => {
+ updatePlan(planDocId, (p) => ({
+ ...p,
+ steps: [{ ...p.steps[0], evidence: [...(p.steps[0].evidence ?? []), `e${(p.steps[0].evidence?.length ?? 0) + 1}`] }],
+ }))
+ return { tokensUsed: 200 }
+ })
+ const loop = makeGoalLoop(deps({ executor, netTokenBudget: true }, clock))
+ const handle = await Effect.runPromise(loop.start(spec(planDocId, { stallThreshold: 99 })))
+ for (let i = 0; i < 2; i++) await Effect.runPromise(loop.tick(handle))
+ const status = await Effect.runPromise(loop.status(handle))
+ expect(status.ledger.tokens).toBe(400) // 2 × 200, no breakdown → gross fallback
+ })
+
+ test("the scope marker survives persist/load — a NET goal recovers as NET after restart", async () => {
+ const clock = new FakeClock()
+ const planDocId = putPlan([step("a", "active")])
+ const loop1 = makeGoalLoop(deps({ executor: granularExecutor, netTokenBudget: true }, clock))
+ const handle = await Effect.runPromise(loop1.start(spec(planDocId, { stallThreshold: 99 })))
+ await Effect.runPromise(loop1.tick(handle))
+ const before = await Effect.runPromise(loop1.status(handle))
+ expect(before.ledger.tokens).toBe(150) // net
+
+ // Rebuild the store from disk + a fresh Controller. CRUCIALLY, the new Controller has netTokenBudget
+ // UNSET (flag "off") — the persisted budgetTokenScope marker must still drive NET accumulation, proving
+ // tick() obeys the marker, not the live flag.
+ const store2 = new DocumentStore(root)
+ store = store2
+ const loop2 = makeGoalLoop(deps({ executor: granularExecutor, store: store2 }, clock))
+ const recovered = await Effect.runPromise(loop2.status(handle))
+ expect(recovered.ledger.tokens).toBe(150) // recovered net ledger, not reset
+ await Effect.runPromise(loop2.tick(handle))
+ const after = await Effect.runPromise(loop2.status(handle))
+ expect(after.ledger.tokens).toBe(300) // continues NET (150+150), NOT gross — marker survived
+ })
+
+ test("a GROSS goal stays GROSS even if the flag is later turned on (no mid-flight re-interpretation)", async () => {
+ const clock = new FakeClock()
+ const planDocId = putPlan([step("a", "active")])
+ // Start with the flag OFF (gross).
+ const loop1 = makeGoalLoop(deps({ executor: granularExecutor }, clock))
+ const handle = await Effect.runPromise(loop1.start(spec(planDocId, { stallThreshold: 99 })))
+ await Effect.runPromise(loop1.tick(handle))
+ // A new Controller with the flag now ON — but the goal was stamped "gross" at creation.
+ const loop2 = makeGoalLoop(deps({ executor: granularExecutor, netTokenBudget: true }, clock))
+ await Effect.runPromise(loop2.tick(handle))
+ const status = await Effect.runPromise(loop2.status(handle))
+ expect(status.ledger.tokens).toBe(2_100) // 2 × 1050 gross — stays gross despite the flag flip
+ })
+})
+
+// V4.0.1 P3(a) — tick idempotency / crash recovery. The infrastructure already exists (durable command
+// cursor + plan-version dedup on the event path; shared run_context doc for both drivers); these tests
+// pin the DURABILITY guarantees that the exactly-once story rests on. `persistPendingPlanEdit` /
+// `readPendingPlanEdit` are pure functions over a DocumentStore, so "cold recovery" is modeled by opening
+// a FRESH DocumentStore handle over the same on-disk root — the exact "second process" reconstruction the
+// event-driven cold-recovery test uses (goal-tick-cold-recovery.test.ts).
+describe("V4.0.1 P3(a) — pendingPlanEdit durability + cold recovery", () => {
+ const GOAL = "g-pending-1"
+ const editInput: PlanInput = { goal: "reach the goal", steps: [{ title: "revised", status: "pending" }] }
+
+ test("a persisted pending edit survives a process restart (fresh store over the same root)", () => {
+ persistPendingPlanEdit(store, SESSION, GOAL, editInput)
+
+ // Simulate a process restart: NOTHING in memory, only the run_context doc on disk. A brand-new store
+ // handle over the same root must reconstruct the pending edit byte-for-byte.
+ const recovered = readPendingPlanEdit(new DocumentStore(root), SESSION, GOAL)
+ expect(recovered).toEqual(editInput)
+ })
+
+ test("consume-once: writing the null sentinel clears the edit, and a re-read after restart stays cleared", () => {
+ persistPendingPlanEdit(store, SESSION, GOAL, editInput)
+ expect(readPendingPlanEdit(store, SESSION, GOAL)).toEqual(editInput)
+
+ // markPlanEditConsumed persists the empty-body sentinel (plan == null). Once consumed it must never
+ // re-materialize — not on this handle, and not after a cold restart.
+ persistPendingPlanEdit(store, SESSION, GOAL, null)
+ expect(readPendingPlanEdit(store, SESSION, GOAL)).toBeNull()
+ expect(readPendingPlanEdit(new DocumentStore(root), SESSION, GOAL)).toBeNull()
+ })
+
+ test("a pending edit is keyed per goal — reading a different goalId never returns another goal's edit", () => {
+ persistPendingPlanEdit(store, SESSION, GOAL, editInput)
+ // pending_edit_goal_id scopes the doc: a sibling goal in the same session sees nothing.
+ expect(readPendingPlanEdit(new DocumentStore(root), SESSION, "g-other")).toBeNull()
+ expect(readPendingPlanEdit(new DocumentStore(root), SESSION, GOAL)).toEqual(editInput)
+ })
+
+ test("the latest write wins across a restart (a newer edit persisted after read is preserved)", () => {
+ persistPendingPlanEdit(store, SESSION, GOAL, editInput)
+ const newer: PlanInput = { goal: "reach the goal", steps: [{ title: "newer step", status: "active" }] }
+ // A second edit lands (e.g. the user revised again before the first was consumed) — the durable doc
+ // reflects the newest content, and a cold reader sees it.
+ persistPendingPlanEdit(store, SESSION, GOAL, newer)
+ expect(readPendingPlanEdit(new DocumentStore(root), SESSION, GOAL)).toEqual(newer)
+ })
+
+ test("the pending-edit doc is invisible to the goal-tick cursor (kept off loadState's run_context match)", () => {
+ // The pending-edit doc uses `pending_edit_goal_id`, NOT `goal_id`, so it must never be mistaken for the
+ // goal's run-state doc — otherwise readGoalTickCursor could parse the edit body as GoalRuntimeState.
+ persistPendingPlanEdit(store, SESSION, GOAL, editInput)
+ expect(readGoalTickCursor(new DocumentStore(root), SESSION, GOAL)).toBeNull()
+ })
+})
diff --git a/packages/core/test/deepagent/world-state.test.ts b/packages/core/test/deepagent/world-state.test.ts
new file mode 100644
index 00000000..c5541c2b
--- /dev/null
+++ b/packages/core/test/deepagent/world-state.test.ts
@@ -0,0 +1,122 @@
+import { describe, expect, test, beforeEach, afterEach } from "bun:test"
+import { mkdtempSync, rmSync } from "node:fs"
+import { tmpdir } from "node:os"
+import path from "node:path"
+import { DocumentStore } from "../../src/deepagent/document-store"
+import * as WorldState from "../../src/deepagent/context/world-state"
+import * as Bridge from "../../src/deepagent/context/bridge"
+
+// V4.0.1 P1 (§3.6) — World State snapshot-diff + tail re-injection invariants.
+
+describe("world state — upsertSlot snapshot-diff (§3.6)", () => {
+ test("a brand-new slot starts at version 1", () => {
+ const ws = WorldState.upsertSlot(WorldState.emptyWorldState("p"), "vcs", "branch main")
+ expect(ws.slots).toHaveLength(1)
+ expect(ws.slots[0]!.version).toBe(1)
+ expect(ws.slots[0]!.value).toBe("branch main")
+ })
+
+ test("value UNCHANGED ⇒ no version bump (returns the same state, byte-stable)", () => {
+ const a = WorldState.upsertSlot(WorldState.emptyWorldState("p"), "vcs", "branch main", 100)
+ const b = WorldState.upsertSlot(a, "vcs", "branch main", 999)
+ expect(b).toBe(a) // identical reference — no churn
+ expect(b.slots[0]!.version).toBe(1)
+ expect(b.slots[0]!.updatedAt).toBe(100) // timestamp not touched on a no-op
+ })
+
+ test("value CHANGED ⇒ version bumps + timestamp updates", () => {
+ const a = WorldState.upsertSlot(WorldState.emptyWorldState("p"), "vcs", "branch main", 100)
+ const b = WorldState.upsertSlot(a, "vcs", "branch feature", 200)
+ expect(b.slots[0]!.version).toBe(2)
+ expect(b.slots[0]!.value).toBe("branch feature")
+ expect(b.slots[0]!.updatedAt).toBe(200)
+ })
+})
+
+describe("world state — collectSlots preserves prior on non-collection", () => {
+ test("omitted / empty kind keeps the prior slot value; present kinds are upserted", () => {
+ let ws = WorldState.emptyWorldState("p")
+ ws = WorldState.collectSlots(ws, { vcs: "branch main", diagnostics: "clean" }, 1)
+ // Next tick: diagnostics could not be recomputed (undefined) and env is empty string.
+ ws = WorldState.collectSlots(ws, { vcs: "branch feature", diagnostics: undefined, env: " " }, 2)
+ const byKind = (k: WorldState.WorldStateSlotKind) => ws.slots.find((s) => s.kind === k)
+ expect(byKind("vcs")!.value).toBe("branch feature")
+ expect(byKind("vcs")!.version).toBe(2)
+ expect(byKind("diagnostics")!.value).toBe("clean") // preserved (non-collection)
+ expect(byKind("diagnostics")!.version).toBe(1)
+ expect(byKind("env")).toBeUndefined() // empty string was never collected
+ })
+})
+
+describe("world state — renderWorldState tail byte-stability + responsibility separation", () => {
+ test("unchanged slots ⇒ byte-identical render; only the changed slot's segment moves", () => {
+ let ws = WorldState.emptyWorldState("p")
+ ws = WorldState.collectSlots(ws, { open_files: "src/a.ts: entry", vcs: "branch main", env: "node v1" }, 1)
+ const r1 = WorldState.renderWorldState(ws)
+ // A tick that changed nothing renders byte-for-byte identically (near-end cache stays warm).
+ const wsSame = WorldState.collectSlots(ws, { open_files: "src/a.ts: entry", vcs: "branch main", env: "node v1" }, 2)
+ expect(WorldState.renderWorldState(wsSame)).toBe(r1)
+ // Change only vcs: env + open_files segments are byte-identical; only the vcs line differs.
+ const wsChanged = WorldState.collectSlots(ws, { vcs: "branch feature" }, 3)
+ const r2 = WorldState.renderWorldState(wsChanged)
+ expect(r2).toContain("node v1") // env segment unchanged
+ expect(r2).toContain("src/a.ts: entry") // open_files segment unchanged
+ expect(r2).toContain("branch feature")
+ expect(r2).not.toContain("branch main")
+ })
+
+ test("empty world state renders to '' (⇒ caller injects nothing)", () => {
+ expect(WorldState.renderWorldState(WorldState.emptyWorldState("p"))).toBe("")
+ })
+
+ test("responsibility separation: the render carries the LATEST file value, overriding a stale one", () => {
+ // Simulate a summary having captured a stale file value; World State holds the fresh one. The tail
+ // block re-injects the fresh value with an explicit "trust these over the summary" instruction.
+ let ws = WorldState.upsertSlot(WorldState.emptyWorldState("p"), "open_files", "config.ts: PORT=3000")
+ ws = WorldState.upsertSlot(ws, "open_files", "config.ts: PORT=8080")
+ const rendered = WorldState.renderWorldState(ws)
+ expect(rendered).toContain("PORT=8080") // latest wins
+ expect(rendered).not.toContain("PORT=3000")
+ expect(rendered).toContain("trust these over") // the override instruction
+ })
+})
+
+describe("world state — project-scoped persistence (bridge, gate-free goal-worker read)", () => {
+ let base: string
+ beforeEach(() => {
+ base = mkdtempSync(path.join(tmpdir(), "deepagent-ws-"))
+ })
+ afterEach(() => {
+ rmSync(base, { recursive: true, force: true })
+ })
+
+ test("persist + load round-trips the World State doc; loadWorldStateForGoalWorker reads it too", () => {
+ const store = new DocumentStore(path.join(base, "proj"))
+ let ws = WorldState.emptyWorldState("projX")
+ ws = WorldState.collectSlots(ws, { vcs: "branch main", diagnostics: "clean" }, 1)
+ Bridge.persistWorldState(store, ws)
+
+ const loaded = Bridge.loadWorldState(store, "projX")
+ expect(loaded.slots.map((s) => s.kind).sort()).toEqual(["diagnostics", "vcs"])
+ // The goal-worker path is gate-free (does not go through shouldLoadBridge's general short-circuit).
+ const forWorker = Bridge.loadWorldStateForGoalWorker(store, "projX")
+ expect(forWorker.slots.find((s) => s.kind === "vcs")!.value).toBe("branch main")
+ })
+
+ test("unchanged persist bumps NO doc version (content-addressed no-op ⇒ stable tail across ticks)", () => {
+ const store = new DocumentStore(path.join(base, "proj2"))
+ let ws = WorldState.collectSlots(WorldState.emptyWorldState("projY"), { vcs: "branch main" }, 1)
+ Bridge.persistWorldState(store, ws)
+ const first = store.list({ type: "context_snapshot", scope: "durable:project:projY" })[0]!
+ const v1 = store.get(first.id)!.version
+ // Re-persist the SAME (snapshot-diffed) state → identical body → no new version.
+ ws = WorldState.collectSlots(ws, { vcs: "branch main" }, 2)
+ Bridge.persistWorldState(store, ws)
+ expect(store.get(first.id)!.version).toBe(v1)
+ })
+
+ test("absent doc ⇒ empty world state (never throws)", () => {
+ const store = new DocumentStore(path.join(base, "proj3"))
+ expect(Bridge.loadWorldState(store, "none").slots).toHaveLength(0)
+ })
+})
diff --git a/packages/core/test/global.test.ts b/packages/core/test/global.test.ts
index 722b2804..d42817eb 100644
--- a/packages/core/test/global.test.ts
+++ b/packages/core/test/global.test.ts
@@ -18,4 +18,12 @@ describe("global paths", () => {
expect(Global.Path.data).toBe(path.join(Global.Path.home, ".deepagent", "code"))
expect(Global.Path.agent.runs).toBe(path.join(Global.Path.data, "runs"))
})
+
+ test("config shares the data root after unification", () => {
+ expect(Global.Path.config).toBe(Global.Path.data)
+ })
+
+ test("config directory is created on module load", async () => {
+ expect((await fs.stat(Global.Path.config)).isDirectory()).toBe(true)
+ })
})
diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts
index 898a44d5..2dd1e3e4 100644
--- a/packages/core/test/session-compaction.test.ts
+++ b/packages/core/test/session-compaction.test.ts
@@ -16,3 +16,22 @@ test("compaction describes tool media without embedding base64", () => {
expect(serialized).toBe("Image read successfully\n[Attached image/png: pixel.png]")
expect(serialized).not.toContain(base64)
})
+
+// V4.0.1 P1 §3.4 — the four-bucket NARROW summary template (gated by worldStateReinjection at the
+// deepagent-code call site). narrow OFF ⇒ the legacy template (byte-for-byte pre-V4.0.1).
+test("buildPrompt narrow=true uses the four-bucket template and forbids file/env/diagnostics snapshots", () => {
+ const narrow = SessionCompaction.buildPrompt({ context: [], narrow: true })
+ expect(narrow).toContain("## Progress & Key Decisions")
+ expect(narrow).toContain("## Data References")
+ expect(narrow).toContain("Do NOT record file contents")
+ // The narrowed template drops the legacy "Relevant Files" / "Critical Context" content buckets.
+ expect(narrow).not.toContain("## Relevant Files")
+ expect(narrow).not.toContain("## Critical Context")
+})
+
+test("buildPrompt narrow omitted ⇒ legacy template (unchanged)", () => {
+ const legacy = SessionCompaction.buildPrompt({ context: [] })
+ expect(legacy).toContain("## Relevant Files")
+ expect(legacy).toContain("## Critical Context")
+ expect(legacy).not.toContain("## Data References")
+})
diff --git a/packages/deepagent-code/src/cli/cmd/uninstall.ts b/packages/deepagent-code/src/cli/cmd/uninstall.ts
index 9a2fc0fc..77e57d24 100644
--- a/packages/deepagent-code/src/cli/cmd/uninstall.ts
+++ b/packages/deepagent-code/src/cli/cmd/uninstall.ts
@@ -88,13 +88,32 @@ export const UninstallCommand = {
}
async function collectRemovalTargets(args: UninstallArgs, method: Installation.Method): Promise {
- const directories: RemovalTargets["directories"] = [
+ const raw: RemovalTargets["directories"] = [
{ path: Global.Path.data, label: "Data", keep: args.keepData },
{ path: Global.Path.cache, label: "Cache", keep: false },
{ path: Global.Path.config, label: "Config", keep: args.keepConfig },
{ path: Global.Path.state, label: "State", keep: false },
]
+ // After config/data unification, Config, Cache and State are all nested under (or equal to) the
+ // data root. Collapse entries that resolve to the same path or that sit inside a kept ancestor so
+ // we never (a) list the same dir twice, or (b) delete Data out from under a --keep-config request.
+ // keep is unioned: if any alias of a path is kept, the path is kept.
+ const byPath = new Map()
+ for (const dir of raw) {
+ const key = path.resolve(dir.path)
+ const existing = byPath.get(key)
+ if (existing) existing.keep = existing.keep || dir.keep
+ else byPath.set(key, { ...dir, path: key })
+ }
+ const directories = [...byPath.values()].filter((dir) => {
+ // Drop a dir if a *different* kept dir is an ancestor of it — that ancestor's removal is skipped,
+ // so the nested dir survives too and must not be independently deleted.
+ return ![...byPath.values()].some(
+ (other) => other !== dir && other.keep && (dir.path === other.path || dir.path.startsWith(other.path + path.sep)),
+ )
+ })
+
const shellConfig = method === "curl" ? await getShellConfigFile() : null
const binary = method === "curl" ? process.execPath : null
diff --git a/packages/deepagent-code/src/config/config.ts b/packages/deepagent-code/src/config/config.ts
index 6f9b7224..02ff9d03 100644
--- a/packages/deepagent-code/src/config/config.ts
+++ b/packages/deepagent-code/src/config/config.ts
@@ -182,7 +182,7 @@ export class Service extends Context.Service()("@deepagent-c
export const use = serviceUse(Service)
function globalConfigFile() {
- const candidates = ["deepagent-code.jsonc", "deepagent-code.json", "config.json"].map((file) =>
+ const candidates = ["config.jsonc", "config.json", "deepagent-code.jsonc", "deepagent-code.json"].map((file) =>
path.join(Global.Path.config, file),
)
for (const file of candidates) {
@@ -193,10 +193,11 @@ function globalConfigFile() {
// Single canonical global config file. We still LOAD the legacy names for backward compatibility
// (loadGlobal merges all of them), but users should only ever have to edit ONE file. This
-// consolidates any legacy config.json / deepagent-code.json into deepagent-code.jsonc at startup
-// and removes the old files, so plugins and providers no longer end up split across files.
-const CANONICAL_GLOBAL_CONFIG = "deepagent-code.jsonc"
-const LEGACY_GLOBAL_CONFIGS = ["config.json", "deepagent-code.json"]
+// consolidates any legacy config.json / deepagent-code.json / deepagent-code.jsonc into the single
+// canonical config.jsonc at startup and removes the old files, so plugins and providers no longer end
+// up split across files. config.jsonc lives at the data root (~/.deepagent/code) after unification.
+const CANONICAL_GLOBAL_CONFIG = "config.jsonc"
+const LEGACY_GLOBAL_CONFIGS = ["config.json", "deepagent-code.json", "deepagent-code.jsonc"]
async function migrateGlobalConfigFiles() {
const dir = Global.Path.config
@@ -220,8 +221,9 @@ async function migrateGlobalConfigFiles() {
}
}
- // Load order = config.json -> deepagent-code.json -> deepagent-code.jsonc, so the canonical
- // .jsonc wins over legacy. Build the merged object in that precedence.
+ // Load order = config.json -> deepagent-code.json -> deepagent-code.jsonc, so the newest legacy
+ // (.jsonc) wins over older ones. Build the merged object in that precedence; the canonical
+ // config.jsonc (patched below) then wins over all of them.
let merged: Record = {}
for (const file of legacyPaths) {
const parsed = parseStrict(await fsNode.readFile(file, "utf8").catch(() => ""))
@@ -245,9 +247,23 @@ async function migrateGlobalConfigFiles() {
}
await fsNode.writeFile(canonicalPath, text)
} else {
- merged.$schema ??= "https://deepagent-code.ai/config.json"
+ // No canonical file yet. Prefer renaming from a legacy .jsonc so we carry the user's comments
+ // over (the common upgrade path: deepagent-code.jsonc -> config.jsonc). Fold in keys from any
+ // .json-only legacy files on top. Fall back to a plain serialize when no .jsonc legacy exists.
+ const jsoncBase = [...legacyPaths].reverse().find((file) => file.endsWith(".jsonc"))
await fsNode.mkdir(dir, { recursive: true }).catch(() => {})
- await fsNode.writeFile(canonicalPath, JSON.stringify(merged, null, 2))
+ if (jsoncBase) {
+ let text = await fsNode.readFile(jsoncBase, "utf8").catch(() => "{}")
+ const baseParsed = parseStrict(text) ?? {}
+ for (const [key, value] of Object.entries(merged)) {
+ if (key in baseParsed) continue
+ text = patchJsonc(text, { [key]: value })
+ }
+ await fsNode.writeFile(canonicalPath, text)
+ } else {
+ merged.$schema ??= "https://deepagent-code.ai/config.json"
+ await fsNode.writeFile(canonicalPath, JSON.stringify(merged, null, 2))
+ }
}
for (const file of legacyPaths) await fsNode.unlink(file).catch(() => {})
@@ -547,9 +563,13 @@ export const layer = Layer.effect(
.pipe(Effect.catch(() => Effect.void))
}
}
+ // Merge in precedence order: legacy names first, then the canonical config.jsonc last so it
+ // wins. All names are still loaded for backward compatibility until migration removes the old
+ // files; migrateGlobalConfigFiles() consolidates them into config.jsonc on startup.
result = mergeConfig(result, yield* loadFileSafe(path.join(Global.Path.config, "config.json")))
result = mergeConfig(result, yield* loadFileSafe(path.join(Global.Path.config, "deepagent-code.json")))
result = mergeConfig(result, yield* loadFileSafe(path.join(Global.Path.config, "deepagent-code.jsonc")))
+ result = mergeConfig(result, yield* loadFileSafe(path.join(Global.Path.config, "config.jsonc")))
const legacy = path.join(Global.Path.config, "config")
if (existsSync(legacy)) {
diff --git a/packages/deepagent-code/src/config/tui-migrate.ts b/packages/deepagent-code/src/config/tui-migrate.ts
index ac7dcdef..92529cb1 100644
--- a/packages/deepagent-code/src/config/tui-migrate.ts
+++ b/packages/deepagent-code/src/config/tui-migrate.ts
@@ -136,6 +136,9 @@ async function backupAndStripLegacy(file: string, source: string) {
async function deepagentCodeFiles(input: { directories: string[]; cwd: string }) {
const files = [
+ // Global config after unification is config.jsonc/config.json at the data root; keep the old
+ // deepagent-code.* names too so legacy tui keys are still stripped from un-migrated files.
+ ...ConfigPaths.fileInDirectory(Global.Path.config, "config"),
...ConfigPaths.fileInDirectory(Global.Path.config, "deepagent-code"),
...(await Filesystem.findUp(["deepagent-code.json", "deepagent-code.jsonc"], input.cwd, undefined, {
rootFirst: true,
diff --git a/packages/deepagent-code/src/effect/runtime-flags.ts b/packages/deepagent-code/src/effect/runtime-flags.ts
index 140a2f27..32461a30 100644
--- a/packages/deepagent-code/src/effect/runtime-flags.ts
+++ b/packages/deepagent-code/src/effect/runtime-flags.ts
@@ -103,6 +103,53 @@ export class Service extends ConfigService.Service()("@deepagent-code/R
experimentalGoalLoop: stableOn("DEEPAGENT_CODE_EXPERIMENTAL_GOAL_LOOP"),
experimentalIconDiscovery: enabledByExperimental("DEEPAGENT_CODE_EXPERIMENTAL_ICON_DISCOVERY"),
outputTokenMax: positiveInteger("DEEPAGENT_CODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
+ // V4.0.1 P0: three-layer SOFT-LANDING compaction (reminder → fallback "death notes" → hard rollover).
+ // Before a hard LLM-summary compaction, the turn loop gives the model a reminder (soft line) and then a
+ // one-shot forced "临终笔记" fallback message (all tools retained) so it can flush un-persisted decisions /
+ // next-step intent into the durable plan doc BEFORE lossy summarization. Pure-additive, strictly safer
+ // default (loses less on compaction), no autonomous side effects → SHIPS ON (mirrors v4Steering posture).
+ // With `=false`, overflowStatus() collapses to the pre-V4.0.1 single-threshold ok/hard behavior (逐字节
+ // equivalent). Also respects DEEPAGENT_CODE_DISABLE_AUTOCOMPACT (no compaction → no soft-landing).
+ softLandingCompaction: stableOn("DEEPAGENT_CODE_SOFT_LANDING_COMPACTION"),
+ // V4.0.1 P0b: OUTPUT soft-landing — when a response is cut off at the output-token ceiling
+ // (finish === "length") with no pending tool call, instead of ending the turn (the pre-V4.0.1 behavior),
+ // inject a "continue from where you were cut off, do not repeat" tail message and loop once more so the
+ // model resumes. Bounded by OUTPUT_CONTINUATION_MAX consecutive continuations (reset on any natural stop)
+ // to prevent an infinite loop. Improves on Codex, which treats an output-cap hit as a retryable stream
+ // error and re-sends the identical request (re-hitting the same cap). Pure-additive, strictly better
+ // default (a truncated long answer now completes instead of stopping mid-sentence) → SHIPS ON. With
+ // `=false` a length-capped response ends the turn exactly as before V4.0.1.
+ outputSoftLanding: stableOn("DEEPAGENT_CODE_OUTPUT_SOFT_LANDING"),
+ // V4.0.1 P2: goal BUDGET soft-notify — when cost/maxCost crosses tiered fractions (default [0.7, 0.9]),
+ // inject a "converge, don't expand" reminder into the next tick's step-prompt TAIL (never the prefix),
+ // mirroring Codex's . Pure-additive reminder with NO halting behavior change → SHIPS ON.
+ // With `=false` no budget notice is injected (pre-V4.0.1 behavior).
+ goalBudgetSoftNotify: stableOn("DEEPAGENT_CODE_GOAL_BUDGET_SOFT_NOTIFY"),
+ // V4.0.1 P2: goal NET-token budget accounting. When on, BudgetLedger.tokens accumulates NET generation
+ // (output + max(0, input − carriedPrefixTokens)) instead of gross throughput, so a long task's ledger no
+ // longer inflates linearly from the repeated static prefix each tick. This CHANGES the accumulation
+ // semantics of an already-persisted ledger, so it defaults OFF and only applies to goals CREATED after it
+ // is enabled (each goal stamps a `budgetTokenScope: "gross" | "net"` marker; loadState picks the
+ // accumulation by marker — never mid-flight). NOTE: token overflow no longer halts a goal regardless of
+ // this flag (that halt was removed in P2); this flag only governs the ledger COUNTING convention.
+ // Promoted ON (V4.0.1): net accounting is the CORRECT ledger convention (a long task's ledger no longer
+ // inflates linearly from the repeated static prefix). Safe as a default because it is stamped PER-GOAL at
+ // creation via `budgetTokenScope` — only goals created after this is on accumulate "net"; every
+ // already-persisted ledger keeps its stamped "gross" scope and is never re-interpreted mid-flight. Set
+ // `=false` to force new goals back to the pre-V4.0.1 gross throughput accounting.
+ goalNetTokenBudget: stableOn("DEEPAGENT_CODE_GOAL_NET_TOKEN_BUDGET"),
+ // V4.0.1 P1: World State / summary responsibility separation. When on, the compaction summary is narrowed
+ // to four buckets (progress+decisions / constraints+prefs / next steps / data references) and files /
+ // env / diagnostics are carried by a snapshot-diff World State layer re-injected as a TAIL user block at
+ // tick start + after each hard compaction (never the static prefix). Also opens a "always load World
+ // State" path for the goal-worker (P3(d)), bypassing shouldLoadBridge's general short-circuit. Because it
+ // alters context assembly. Promoted ON (V4.0.1): it is a pure tail-only re-injection (never the static
+ // prefix) and the summary narrowing keeps the LLM summary focused, so it is safe as a default; with
+ // `=false` the summary keeps the legacy "record everything" template and nothing is re-injected (逐字节
+ // equivalent to V4.1). The summary
+ // narrowing and the re-injection MUST be gated by this single flag together (splitting them would create a
+ // "summary drops files, nothing re-injects" information hole).
+ worldStateReinjection: stableOn("DEEPAGENT_CODE_WORLD_STATE_REINJECTION"),
// T3 (S1-v3.4): how many narrowing attempts a 🟡 stall is given before it escalates to 🔴.
// Default 1 (one focused retry, then hand off). `positiveInteger` → undefined when unset/invalid,
// so the loop applies its own default of 1.
@@ -128,13 +175,26 @@ export class Service extends ConfigService.Service()("@deepagent-code/R
// policy gate. HIGH-RISK (side-effecting outbound) — operator opt-in. Enable with DEEPAGENT_CODE_V4_AGENT_PUSH_ENABLED=true.
v4AgentPushEnabled: bool("DEEPAGENT_CODE_V4_AGENT_PUSH_ENABLED"),
// §C: the Multi-Agent Runtime (coordinated multi-agent execution over the bus + agent.task.*
- // coordination). This is the master switch that starts the event-runtime daemons. HIGH-RISK —
- // operator opt-in. Enable with DEEPAGENT_CODE_V4_MULTI_AGENT_RUNTIME=true.
- v4MultiAgentRuntime: bool("DEEPAGENT_CODE_V4_MULTI_AGENT_RUNTIME"),
- // §D: permit autonomy level 2 (act-then-report on reversible actions, subject to the Oversight
- // ceiling + Approval Queue). HIGH-RISK (autonomous side effects) — operator opt-in. Enable with
- // DEEPAGENT_CODE_V4_AGENT_AUTONOMY_LEVEL_2=true.
- v4AgentAutonomyLevel2: bool("DEEPAGENT_CODE_V4_AGENT_AUTONOMY_LEVEL_2"),
+ // coordination). This is the master switch that starts the event-runtime daemons — including the V4.1
+ // §N event-driven goal-tick chain (GoalTickConsumer + cold-recovery port). PROMOTED ON by default (V4.1):
+ // the daemon audit is GO (every gated daemon is complete + correctly started, InstanceRef-die fix at both
+ // sites, approval keys match, goal.tick is now a real consumed command with cross-process cold recovery),
+ // and autonomous level_2 edits are the INTENDED semantic of this flag (governed by each agent descriptor's
+ // declared autonomy ceiling, guarded by the four-layer SecurityGate + Approval Queue + concurrency cap +
+ // file locks + trusted-source gating on external events — external webhooks stay fail-closed until an
+ // operator opts their source into the workspace trustedSources). Set
+ // DEEPAGENT_CODE_V4_MULTI_AGENT_RUNTIME=false to restore the pre-V4 (V3.8-equivalent) inert posture: no
+ // daemons subscribe, ticks run via the in-process BackgroundJob driver, nothing is autonomous.
+ v4MultiAgentRuntime: stableOn("DEEPAGENT_CODE_V4_MULTI_AGENT_RUNTIME"),
+ // NOTE: there is deliberately NO separate "autonomy level 2" flag. The §D autonomy gate
+ // (AutonomyPolicy.decide in multi-agent-runtime.ts) is driven purely by each agent's DECLARED autonomy
+ // ceiling in its descriptor — a flag could only ever have MASKED that, and a former
+ // v4AgentAutonomyLevel2 flag was inert (advertised in /global/capabilities but wired to neither the UI
+ // nor the gate), so it was removed rather than left as a control that controls nothing. Enabling
+ // v4MultiAgentRuntime authorizes autonomous action up to each agent's own ceiling (builtin fixers are
+ // level_2 = act-then-report), guarded by the four-layer SecurityGate + Approval Queue + concurrency cap
+ // + file locks + trusted-source gating on external events. To restrict autonomy, tighten the agent
+ // descriptors' `autonomy` levels — config can only tighten a ceiling, never raise it.
// §B: threaded conversations (thread query + reply grouping on the IM surface). Default OFF (known
// correctness bugs pending). Enable with DEEPAGENT_CODE_V4_THREAD_ENABLED=true.
v4ThreadEnabled: bool("DEEPAGENT_CODE_V4_THREAD_ENABLED"),
diff --git a/packages/deepagent-code/src/provider/discovery-cache.ts b/packages/deepagent-code/src/provider/discovery-cache.ts
new file mode 100644
index 00000000..2df55385
--- /dev/null
+++ b/packages/deepagent-code/src/provider/discovery-cache.ts
@@ -0,0 +1,143 @@
+import path from "path"
+import { Duration, Effect, Option } from "effect"
+import { Global } from "@deepagent-code/core/global"
+import { Hash } from "@deepagent-code/core/util/hash"
+import { Log } from "@deepagent-code/core/util/log"
+import type { FSUtil } from "@deepagent-code/core/fs-util"
+import type { EffectFlock } from "@deepagent-code/core/util/effect-flock"
+import { discoverProviderModels, isChatModel, type DiscoveredModel, type ProviderDiscoveryKind } from "./model-discovery"
+
+const log = Log.create({ service: "provider-discovery-cache" })
+
+// 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
+// length so a hostile or broken endpoint can't blow up memory or the UI.
+const MAX_DISCOVERED_MODELS = 1000
+const MAX_MODEL_ID_LENGTH = 256
+const MAX_MODEL_NAME_LENGTH = 256
+
+// Keep only selectable chat models, drop pathological ids/names, and cap the count. Applied to every
+// discovery result (fresh fetch and the values re-read from cache) so the runtime pre-pass and the
+// interactive discover route agree on what's a usable model.
+const sanitizeModels = (models: DiscoveredModel[]): DiscoveredModel[] => {
+ const seen = new Set()
+ const out: DiscoveredModel[] = []
+ for (const model of models) {
+ if (!model || typeof model.id !== "string") continue
+ const id = model.id.trim()
+ if (!id || id.length > MAX_MODEL_ID_LENGTH) continue
+ if (!isChatModel(id)) continue
+ 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) })
+ if (out.length >= MAX_DISCOVERED_MODELS) break
+ }
+ return out
+}
+
+// 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.
+
+export const DEFAULT_DISCOVERY_TTL = Duration.hours(6)
+
+export interface DiscoverModelsCachedInput {
+ providerID: string
+ baseURL: string
+ // Optional: header-only-auth providers discover without a bearer/x-api-key credential.
+ apiKey?: string
+ kind: ProviderDiscoveryKind
+ headers?: Record
+ ttl?: Duration.Duration
+}
+
+// Cache identity includes the credential and headers, not just providerID+baseURL: rotating the key
+// (or a plan/tier change that alters which models are visible) must invalidate the cache rather than
+// serving the old list until the TTL lapses. The secret is hashed, never stored in the filename.
+const cacheFile = (input: DiscoverModelsCachedInput) => {
+ const headerSig = input.headers
+ ? Object.entries(input.headers)
+ .map(([k, v]) => `${k}=${v}`)
+ .sort()
+ .join("&")
+ : ""
+ const identity = `${input.providerID}\n${input.baseURL}\n${input.kind}\n${input.apiKey}\n${headerSig}`
+ return path.join(Global.Path.cache, `provider-models-${Hash.fast(identity)}.json`)
+}
+
+// `fs` and `flock` are passed in (rather than pulled from context) so callers inside a scoped
+// effect — like the provider state build — don't have to widen their R requirements. `fetch` is
+// injectable for testing; it defaults to the real /models HTTP call.
+export const discoverModelsCached = Effect.fn("ProviderDiscovery.cached")(function* (
+ fs: FSUtil.Interface,
+ flock: EffectFlock.Interface,
+ input: DiscoverModelsCachedInput,
+ fetch: (input: DiscoverModelsCachedInput) => Promise = discoverProviderModels,
+) {
+ const ttl = input.ttl ?? DEFAULT_DISCOVERY_TTL
+ const filepath = cacheFile(input)
+
+ // Re-sanitize on read too: a cache file written by an older build (looser rules) is normalized to
+ // the current bounds before use.
+ const readDisk = fs.readJson(filepath).pipe(
+ Effect.map((v) => (Array.isArray(v) ? sanitizeModels(v as DiscoveredModel[]) : undefined)),
+ Effect.catch(() => Effect.succeed(undefined)),
+ )
+
+ const fresh = Effect.gen(function* () {
+ const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
+ if (!stat) return false
+ const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime()
+ return Date.now() - mtime < Duration.toMillis(ttl)
+ })
+
+ if (yield* fresh) {
+ const cached = yield* readDisk
+ if (cached) return cached
+ }
+
+ const fetchAndWrite = Effect.gen(function* () {
+ const models = sanitizeModels(yield* Effect.tryPromise(() => fetch(input)))
+ // 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
+ // returning nothing.
+ if (models.length === 0) return (yield* readDisk) ?? models
+ const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp`
+ yield* fs.writeWithDirs(tempfile, JSON.stringify(models)).pipe(
+ Effect.andThen(fs.rename(tempfile, filepath)),
+ Effect.catch((error) =>
+ fs
+ .remove(tempfile, { force: true })
+ .pipe(Effect.ignore, Effect.andThen(Effect.fail(error))),
+ ),
+ )
+ return models
+ })
+
+ // 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 [].
+ return yield* flock
+ .withLock(
+ Effect.gen(function* () {
+ if (yield* fresh) {
+ const cached = yield* readDisk
+ if (cached) return cached
+ }
+ return yield* fetchAndWrite
+ }),
+ `provider-models:${filepath}`,
+ )
+ .pipe(
+ Effect.catch((error) =>
+ Effect.gen(function* () {
+ 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 f174b1cc..d6e58211 100644
--- a/packages/deepagent-code/src/provider/model-discovery.ts
+++ b/packages/deepagent-code/src/provider/model-discovery.ts
@@ -32,9 +32,44 @@ function parseModelList(body: { data?: unknown[] }): DiscoveredModel[] {
.filter((item): item is DiscoveredModel => Boolean(item))
}
+export type ProtocolDiscoveryResult = {
+ kind: ProviderDiscoveryKind
+ 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.
+export async function discoverWithProtocol(
+ input: {
+ baseURL: string
+ apiKey: string
+ providerID: string
+ kind?: ProviderDiscoveryKind
+ headers?: Record
+ },
+ probe: (kind: ProviderDiscoveryKind) => Promise = (kind) =>
+ 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
+ }
+ }
+ throw lastError instanceof Error ? lastError : new Error("No provider models were returned")
+}
+
export async function discoverProviderModels(input: {
baseURL: string
- apiKey: string
+ // Optional: some gateways authenticate discovery entirely via custom headers (no bearer/x-api-key).
+ apiKey?: string
providerID: string
kind?: ProviderDiscoveryKind
headers?: Record
@@ -44,11 +79,16 @@ export async function discoverProviderModels(input: {
accept: "application/json",
...(input.headers ?? {}),
}
- if (kind === "anthropic") {
- headers["x-api-key"] = input.apiKey
+ // Only attach a credential header when a key is present; header-only auth comes from input.headers.
+ if (input.apiKey) {
+ if (kind === "anthropic") {
+ headers["x-api-key"] = input.apiKey
+ headers["anthropic-version"] ??= "2023-06-01"
+ } else {
+ headers.authorization = `Bearer ${input.apiKey}`
+ }
+ } else if (kind === "anthropic") {
headers["anthropic-version"] ??= "2023-06-01"
- } else {
- headers.authorization = `Bearer ${input.apiKey}`
}
const response = await fetch(listURL(input.baseURL), { headers })
diff --git a/packages/deepagent-code/src/provider/provider.ts b/packages/deepagent-code/src/provider/provider.ts
index a3ccc23c..336f1ed6 100644
--- a/packages/deepagent-code/src/provider/provider.ts
+++ b/packages/deepagent-code/src/provider/provider.ts
@@ -23,6 +23,9 @@ import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { EffectPromise } from "@/effect/promise"
import { FSUtil } from "@deepagent-code/core/fs-util"
+import { EffectFlock } from "@deepagent-code/core/util/effect-flock"
+import { discoverModelsCached } from "./discovery-cache"
+import type { DiscoveredModel, ProviderDiscoveryKind } from "./model-discovery"
import { isRecord } from "@/util/record"
import { optionalOmitUndefined } from "@deepagent-code/core/schema"
import { ProviderTransform } from "./transform"
@@ -49,6 +52,8 @@ const THIRD_PARTY_PROVIDER_CONFLICT_MESSAGE =
"Provider id conflicts with an official provider. Rename this third-party provider in your config."
const LEGACY_AUTH_KEY_MESSAGE =
"Saved API key is no longer used. Only official providers read keys from the key store. Re-add this as a third-party provider in your config and set the key under options.apiKey."
+const DISCOVERY_EMPTY_MESSAGE =
+ "Runtime model discovery returned no models. Check the base URL, API key, and that the endpoint implements GET /models, or list models explicitly under provider..models."
function providerEnvKey(configuredEnv: string[], envs: Record) {
const configured = configuredEnv.find((item) => envs[item])
@@ -1290,6 +1295,7 @@ export const layer = Layer.effect(
const plugin = yield* Plugin.Service
const modelsDevSvc = yield* ModelsDev.Service
const runtimeFlags = yield* RuntimeFlags.Service
+ const flock = yield* EffectFlock.Service
const state = yield* InstanceState.make(() =>
Effect.gen(function* () {
@@ -1416,6 +1422,54 @@ 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.
+ type ConfigModels = NonNullable<(typeof configProviders)[number][1]["models"]>
+ const discoveredModels: Record = {}
+ // 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()
+ yield* Effect.forEach(
+ configProviders.filter(
+ ([providerID, provider]) =>
+ provider.discovery === true &&
+ // Both reserved hosted ids (the product gateway "deepagent-code" and the routed
+ // "deepagent") own their catalog identity — never run third-party discovery against them.
+ providerID !== "deepagent-code" &&
+ providerID !== "deepagent" &&
+ !isOfficialProviderID(providerID) &&
+ isProviderAllowed(ProviderV2.ID.make(providerID)),
+ ),
+ ([providerID, provider]) =>
+ Effect.gen(function* () {
+ discoveryProviders.add(providerID)
+ const baseURL = provider.options?.baseURL
+ if (!baseURL) return
+ const envKey = provider.env ? providerEnvKey(provider.env, envs) : undefined
+ const apiKey = provider.options?.apiKey ?? (envKey ? envs[envKey] : undefined)
+ const headers = provider.options?.headers as Record | undefined
+ // A key OR custom auth headers must be present; a bare baseURL can't authenticate.
+ if (!apiKey && !headers) return
+ const kind: ProviderDiscoveryKind = provider.npm === "@ai-sdk/anthropic" ? "anthropic" : "openai-compatible"
+ const models = yield* discoverModelsCached(fs, flock, { providerID, baseURL, apiKey, kind, headers })
+ if (!models.length) return
+ discoveredModels[providerID] = Object.fromEntries(
+ models.map((m: DiscoveredModel) => [m.id, { name: m.name || m.id }]),
+ )
+ }).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.catchDefect((defect) =>
+ Effect.sync(() => log.warn("discovery pre-pass defect", { providerID, 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
// provider semantics and auth.
@@ -1441,7 +1495,10 @@ export const layer = Layer.effect(
models: existing?.models ?? {},
}
- for (const [modelID, model] of Object.entries(provider.models ?? {})) {
+ // 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 = { ...(discoveredModels[providerID] ?? {}), ...(provider.models ?? {}) }
+ for (const [modelID, model] of Object.entries(modelSource)) {
const existingModel = parsed.models[model.id ?? modelID]
const apiID = model.id ?? existingModel?.api.id ?? modelID
const apiNpm =
@@ -1704,6 +1761,13 @@ export const layer = Layer.effect(
}
if (Object.keys(provider.models).length === 0) {
+ // A provider that opted into runtime discovery but produced no models (endpoint
+ // unreachable/offline at launch, no /models, empty list, or everything filtered out) would
+ // otherwise vanish from the picker with zero diagnostics. Surface a config error so the
+ // user knows discovery is why their configured provider isn't showing up.
+ if (discoveryProviders.has(providerID)) {
+ errors.push(providerConfigError(providerID, DISCOVERY_EMPTY_MESSAGE))
+ }
delete providers[providerID]
continue
}
@@ -2070,6 +2134,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Plugin.defaultLayer),
Layer.provide(ModelsDev.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
+ Layer.provide(EffectFlock.defaultLayer),
),
)
diff --git a/packages/deepagent-code/src/provider/transform.ts b/packages/deepagent-code/src/provider/transform.ts
index 115ddd5e..6b9b5df6 100644
--- a/packages/deepagent-code/src/provider/transform.ts
+++ b/packages/deepagent-code/src/provider/transform.ts
@@ -1270,8 +1270,14 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a
return { [key]: options }
}
-export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_TOKEN_MAX): number {
- return Math.min(model.limit.output, outputTokenMax) || outputTokenMax
+export function maxOutputTokens(model: Provider.Model, outputTokenMax?: number): number {
+ // (c) per-model 输出上限: prefer the model's real output limit. outputTokenMax is
+ // the global env override (DEEPAGENT_CODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX), undefined
+ // when unset. When unset, use the model's own output limit (falling back to the
+ // legacy OUTPUT_TOKEN_MAX=32k only when the model reports no limit) so a >32k model
+ // is no longer pinned to 32k. When set, it acts as a hard cap that can only tighten.
+ if (outputTokenMax === undefined) return model.limit.output || OUTPUT_TOKEN_MAX
+ return Math.min(model.limit.output || outputTokenMax, outputTokenMax) || outputTokenMax
}
export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 {
diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/groups/global.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/groups/global.ts
index acd0c99f..ce6de3cf 100644
--- a/packages/deepagent-code/src/server/routes/instance/httpapi/groups/global.ts
+++ b/packages/deepagent-code/src/server/routes/instance/httpapi/groups/global.ts
@@ -38,7 +38,6 @@ const GlobalCapabilities = Schema.Struct({
v4EventDrivenIm: Schema.optional(Schema.Boolean),
v4AgentPushEnabled: Schema.optional(Schema.Boolean),
v4MultiAgentRuntime: Schema.optional(Schema.Boolean),
- v4AgentAutonomyLevel2: Schema.optional(Schema.Boolean),
v4ThreadEnabled: Schema.optional(Schema.Boolean),
v4FileUploadEnabled: Schema.optional(Schema.Boolean),
}),
diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/groups/provider.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/groups/provider.ts
index ea676054..e5275d14 100644
--- a/packages/deepagent-code/src/server/routes/instance/httpapi/groups/provider.ts
+++ b/packages/deepagent-code/src/server/routes/instance/httpapi/groups/provider.ts
@@ -58,6 +58,10 @@ export const ProviderDiscoveredModel = Schema.Struct({
export const ProviderModelDiscoverResult = Schema.Struct({
providerID: Schema.String,
baseURL: Schema.String,
+ // The protocol that actually answered discovery. When the client omits `kind`, the server probes
+ // openai-compatible then anthropic and reports whichever succeeded so the client can persist the
+ // matching SDK npm.
+ kind: Schema.Literals(["openai-compatible", "anthropic"]),
models: Schema.Array(ProviderDiscoveredModel),
selected: ProviderDiscoveredModel,
})
diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/global.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/global.ts
index f1173fba..311c5003 100644
--- a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/global.ts
+++ b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/global.ts
@@ -106,7 +106,6 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
v4EventDrivenIm: flags.v4EventDrivenIm,
v4AgentPushEnabled: flags.v4AgentPushEnabled,
v4MultiAgentRuntime: flags.v4MultiAgentRuntime,
- v4AgentAutonomyLevel2: flags.v4AgentAutonomyLevel2,
v4ThreadEnabled: flags.v4ThreadEnabled,
v4FileUploadEnabled: flags.v4FileUploadEnabled,
},
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 3f0a8a92..c9ca8605 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
@@ -3,7 +3,7 @@ import { Auth } from "@/auth"
import { Config } from "@/config/config"
import { ModelsDev } from "@deepagent-code/core/models-dev"
import { Provider } from "@/provider/provider"
-import { discoverProviderModels, isChatModel, normalizeBaseURL } from "@/provider/model-discovery"
+import { discoverWithProtocol, isChatModel, normalizeBaseURL } from "@/provider/model-discovery"
import { mapValues } from "remeda"
import { Effect, Schema } from "effect"
@@ -88,9 +88,11 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
})
if (!apiKey) return yield* Effect.fail(new ProviderModelDiscoverError({ message: "Missing provider API key" }))
- const discovered = yield* Effect.tryPromise({
+ // Honor an explicit kind; otherwise probe openai-compatible then anthropic and report which
+ // protocol answered so the client persists the matching SDK npm.
+ const result = yield* Effect.tryPromise({
try: () =>
- discoverProviderModels({
+ discoverWithProtocol({
providerID,
baseURL,
apiKey,
@@ -100,18 +102,15 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
catch: (error) =>
new ProviderModelDiscoverError({ message: error instanceof Error ? error.message : String(error) }),
})
- if (discovered.length === 0) {
- return yield* Effect.fail(new ProviderModelDiscoverError({ message: "No provider models were returned" }))
- }
- const models = discovered.filter((model) => isChatModel(model.id))
- const selectable = models.length ? models : discovered
+ const models = result.models.filter((model) => isChatModel(model.id))
+ const selectable = models.length ? models : result.models
const requested = ctx.payload.modelID?.trim()
const selected = requested
? (selectable.find((model) => model.id === requested) ?? { id: requested, name: requested })
: selectable[0]
- return { providerID, baseURL, models: selectable, selected }
+ return { providerID, baseURL, kind: result.kind, models: selectable, selected }
})
const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: {
diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/server.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/server.ts
index a5450cf3..270a8e2a 100644
--- a/packages/deepagent-code/src/server/routes/instance/httpapi/server.ts
+++ b/packages/deepagent-code/src/server/routes/instance/httpapi/server.ts
@@ -43,6 +43,7 @@ import { LLM } from "@/session/llm"
import { SessionPrompt } from "@/session/prompt"
import { GoalManager } from "@/session/goal-manager"
import { SessionRevert } from "@/session/revert"
+import { SessionSteer } from "@/session/steer"
import { SessionRunState } from "@/session/run-state"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
@@ -347,6 +348,10 @@ export function createRoutes(
SessionPrompt.defaultLayer,
GoalManager.defaultLayer,
SessionRevert.defaultLayer,
+ // V4.1 §N — the durable goal-steer buffer, exposed at the graph root so the v4-event-runtime's
+ // GoalTickConsumer cold port can drain goal-directed steers (GoalManager self-provides its own for
+ // the warm path; the daemon needs it at the shared graph root).
+ SessionSteer.defaultLayer,
SessionShare.defaultLayer,
SessionRunState.defaultLayer,
SessionStatus.defaultLayer,
diff --git a/packages/deepagent-code/src/session/compaction.ts b/packages/deepagent-code/src/session/compaction.ts
index a23870f6..0bab9b37 100644
--- a/packages/deepagent-code/src/session/compaction.ts
+++ b/packages/deepagent-code/src/session/compaction.ts
@@ -356,7 +356,12 @@ export const layer = Layer.effect(
{ sessionID: input.sessionID },
{ context: [], prompt: undefined },
)
- const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
+ // V4.0.1 P1 (§3.4/§3.5): narrow the summary to four buckets ONLY when worldStateReinjection is on —
+ // it MUST be the same flag that gates re-injection, else "summary drops files, nothing re-injects"
+ // opens an information hole. Flag OFF ⇒ legacy SUMMARY_TEMPLATE, byte-for-byte pre-V4.0.1.
+ const nextPrompt =
+ compacting.prompt ??
+ buildPrompt({ previousSummary, context: compacting.context, narrow: flags.worldStateReinjection })
const msgs = structuredClone(selected.head)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
diff --git a/packages/deepagent-code/src/session/context-ledger.ts b/packages/deepagent-code/src/session/context-ledger.ts
index 46f1e622..0faa59f7 100644
--- a/packages/deepagent-code/src/session/context-ledger.ts
+++ b/packages/deepagent-code/src/session/context-ledger.ts
@@ -19,7 +19,11 @@ import type { SessionID } from "./schema"
// Curator). This gives us a real, persisted, per-turn-incremental ledger to build on without touching
// the live compaction behavior.
-const { SessionLedger, ProjectBridge } = DeepAgentContext
+import os from "node:os"
+import { gitGroundTruth } from "../deepagent/git-groundtruth"
+import type { WorldStateSlotKind } from "@deepagent-code/core/deepagent/context/world-state"
+
+const { SessionLedger, ProjectBridge, WorldState } = DeepAgentContext
const { DocumentStore } = DeepAgentDocumentStore
// Run-scoped DocumentStore root for a session's context docs. Reuses the SAME storage base
@@ -44,7 +48,9 @@ export const parseSummaryToEntries = (summary: string): DeepAgentContext.Session
if (t.includes("done")) return "done"
if (t.includes("next")) return "next"
if (t.includes("blocked") || t.includes("open") || t.includes("in progress")) return "open"
- if (t.includes("file")) return "artifact"
+ // V4.0.1 P1 (§3.4): the narrowed template's "Data References" bucket → artifact (reference only,
+ // never content). The legacy "Relevant Files" heading (contains "file") maps here too.
+ if (t.includes("file") || t.includes("reference") || t.includes("data")) return "artifact"
return null
}
for (const raw of lines) {
@@ -214,4 +220,64 @@ export const loadForkOrigin = (sessionID: SessionID): ForkOrigin | undefined =>
}
}
+// --- V4.0.1 P1 (§3.3) World State — snapshot-diff volatile facts, re-injected as a tail block --------
+//
+// The seam between the live git/env/diagnostics sources (deepagent-code) and the pure core World State
+// layer. Two steps kept separate so the (bounded) IO is explicit and the store mutation stays sync:
+// 1. `collectVolatileFacts(cwd)` — cheap git + env collection (NO per-turn heavy collectors; §3.3 cost
+// bound: callers invoke this ONCE per tick-start / post-compaction, never per turn).
+// 2. `refreshWorldState({ workspacePath, facts })` — snapshot-diff merge into the project-scoped World
+// State doc + persist + render the tail block. Default-safe (returns "" on any defect).
+//
+// projectId derivation + the project store are the SAME single shared path carryOverToBridge uses, so the
+// goal-loop tick-start read and the post-compaction write agree on one physical World State doc per project.
+
+// Render the git working-tree state as a compact VCS slot value (branch/dirty/changed-file count). Cheap.
+const renderVcs = (git: { changed_files: readonly string[]; diff_stat: string | null }): string => {
+ const dirty = git.changed_files.length
+ const lines = [`changed files: ${dirty}`]
+ if (git.diff_stat) lines.push(git.diff_stat)
+ if (dirty > 0) lines.push(...git.changed_files.slice(0, 20).map((f) => `- ${f}`))
+ return lines.join("\n")
+}
+
+const renderEnv = (): string => [`platform: ${process.platform}`, `node: ${process.version}`, `arch: ${os.arch()}`].join("\n")
+
+// Collect the cheap volatile facts (git + env) as rendered slot values. Best-effort: a git failure just
+// omits the vcs slot (undefined ⇒ prior value preserved by collectSlots). NOT a heavy collector — plain
+// git ground-truth + process facts, bounded by gitGroundTruth's own timeout.
+export const collectVolatileFacts = (
+ cwd: string,
+): Effect.Effect>> =>
+ Effect.promise(async () => {
+ const git = await gitGroundTruth(cwd).catch(() => null)
+ const facts: Partial> = { env: renderEnv() }
+ if (git) facts.vcs = renderVcs(git)
+ return facts
+ }).pipe(Effect.catchCause(() => Effect.succeed({ env: renderEnv() } as Partial>)))
+
+// Snapshot-diff the collected facts into the project's World State doc, persist, and render the tail
+// block. Returns "" when there is nothing to inject or on ANY defect (default-safe — never throws into
+// the turn/tick loop, matching carryOverToBridge's posture). The persisted doc is content-addressed, so
+// an unchanged fact set bumps NO version and the rendered tail stays byte-stable across ticks.
+export const refreshWorldState = (input: {
+ readonly workspacePath: string
+ readonly facts: Partial>
+}): Effect.Effect =>
+ Effect.sync(() => {
+ const projectStore = AgentGateway.DeepAgentKnowledgeSource.isConfigured()
+ ? AgentGateway.DeepAgentKnowledgeSource.projectStoreFor(input.workspacePath).documentStore
+ : DeepAgentDurableKnowledgeStore.openProjectStore(Global.Path.agent.data, input.workspacePath).documentStore
+ const projectId = DeepAgentDurableKnowledgeStore.projectIdForWorkspace(input.workspacePath)
+ const current = ProjectBridge.loadWorldStateForGoalWorker(projectStore, projectId)
+ const next = WorldState.collectSlots(current, input.facts)
+ ProjectBridge.persistWorldState(projectStore, next)
+ return WorldState.renderWorldState(next)
+ }).pipe(
+ Effect.matchCauseEffect({
+ onFailure: () => Effect.succeed(""),
+ onSuccess: (s) => Effect.succeed(s),
+ }),
+ )
+
export { contextStoreRoot }
diff --git a/packages/deepagent-code/src/session/goal-driver.ts b/packages/deepagent-code/src/session/goal-driver.ts
index 8b6c8dbf..afe12c8d 100644
--- a/packages/deepagent-code/src/session/goal-driver.ts
+++ b/packages/deepagent-code/src/session/goal-driver.ts
@@ -252,71 +252,89 @@ export type RunToCompletionInput = {
* when it exited due to pause — the caller learns pause via the ports). Never throws: the loop's tick
* lives on `never`, and the driver wraps status/port calls so a defect cannot crash the background task.
*/
-export const runToCompletion = (input: RunToCompletionInput): Effect.Effect =>
+// The result of ONE driver iteration. `progress` says what the caller should do next:
+// - "stopped" : a cooperative stop fired (loop.stop called) — terminal, do not continue.
+// - "paused" : a cooperative pause fired — NOT terminal; the persisted state resumes later.
+// - "terminal" : the tick produced a terminal outcome (done/needs_human/rolled_back).
+// - "continue" : a normal tick ran; the caller should drive the NEXT tick.
+// This is the SINGLE shared iteration body used by BOTH the in-process for-loop (`runToCompletion`,
+// flag OFF) AND the event-driven consumer (one `goal.tick.requested` = one `runOneTick`, flag ON), so
+// the delicate pause / plan-edit / steer / status logic lives in exactly one place and can't drift.
+export type OneTickResult = {
+ readonly outcome: TickOutcome
+ readonly progress: "stopped" | "paused" | "terminal" | "continue"
+}
+
+/**
+ * Execute exactly ONE tick iteration: stop/pause checks → apply a pending plan edit → absorb steer →
+ * `loop.tick` → stamp consumed steer → publish status. Never throws (all port/status calls are wrapped).
+ * The caller decides whether to loop again (`continue`) or stop (`stopped`/`paused`/`terminal`).
+ */
+export const runOneTick = (
+ loop: ReturnType,
+ input: RunToCompletionInput,
+): Effect.Effect =>
Effect.gen(function* () {
- const loop = makeGoalLoop(input.deps)
const ports = input.ports ?? noopPorts
- const maxIterations = input.maxIterations ?? 10_000
- let last: TickOutcome = "continue"
- for (let i = 0; i < maxIterations; i++) {
- if (yield* safeBool(ports.shouldStop())) {
- yield* safe(loop.stop(input.handle))
- return "needs_human"
- }
- if (yield* safeBool(ports.shouldPause())) {
- // Cooperative suspend: do NOT tick, do NOT mark terminal — the persisted state resumes later.
- // A steer staged BEFORE a pause is NOT stamped consumed (the tick that would absorb it never
- // ran), so it stays pending and is re-drained on resume — no guidance is lost across a pause.
- // A plan edit enqueued during pause likewise stays pending on the control channel and is applied
- // on resume (drained here on the first post-resume iteration), so no edit is lost across a pause.
- return "continue"
- }
+ if (yield* safeBool(ports.shouldStop())) {
+ yield* safe(loop.stop(input.handle))
+ return { outcome: "needs_human", progress: "stopped" }
+ }
+ if (yield* safeBool(ports.shouldPause())) {
+ // Cooperative suspend: do NOT tick, do NOT mark terminal — the persisted state resumes later.
+ // A steer staged BEFORE a pause is NOT stamped consumed (the tick that would absorb it never ran),
+ // so it stays pending and is re-drained on resume — no guidance is lost across a pause. A plan edit
+ // enqueued during pause likewise stays pending and is applied on the first post-resume iteration.
+ return { outcome: "continue", progress: "paused" }
+ }
- // §S2 — apply a pending USER PLAN EDIT BETWEEN ticks (after the PREVIOUS tick's mirror-back, before
- // THIS tick). Draining here — not mid-tick — is what prevents the child-bridge clobber: the prior
- // tick's mirrorChildPlan has already written the child's progress back, so applying the user edit on
- // top preserves both (child progress + user revision). loop.applyPlanEdit upserts the durable doc
- // via the DRIVER's store handle (so the next tick's readPlan sees it) and re-baselines stall/version.
- // consume-once: stamp consumed ONLY after a successful apply; a crash before markPlanEditConsumed
- // leaves the edit pending → re-applied next iteration (idempotent: a fingerprint-identical re-upsert
- // is a no-op, and re-baseline to the same values is harmless).
- if (ports.pendingPlanEdit) {
- const editedPlan = yield* safePlanEdit(ports.pendingPlanEdit())
- if (editedPlan != null) {
- yield* safe(loop.applyPlanEdit(input.handle, editedPlan))
- // Pass the exact edit we applied so the port clears the slot ONLY if it still holds THIS edit —
- // a newer edit admitted while we were applying stays pending and is drained next iteration.
- if (ports.markPlanEditConsumed) yield* safe(ports.markPlanEditConsumed(editedPlan))
- }
+ // §S2 — apply a pending USER PLAN EDIT BETWEEN ticks (after the PREVIOUS tick's mirror-back, before
+ // THIS tick). Draining here — not mid-tick — prevents the child-bridge clobber: the prior tick's
+ // mirrorChildPlan already wrote the child's progress back, so applying the user edit on top preserves
+ // both. consume-once: stamp consumed ONLY after a successful apply; a crash before markPlanEditConsumed
+ // leaves the edit pending → re-applied next iteration (idempotent re-upsert / re-baseline is a no-op).
+ if (ports.pendingPlanEdit) {
+ const editedPlan = yield* safePlanEdit(ports.pendingPlanEdit())
+ if (editedPlan != null) {
+ yield* safe(loop.applyPlanEdit(input.handle, editedPlan))
+ if (ports.markPlanEditConsumed) yield* safe(ports.markPlanEditConsumed(editedPlan))
}
+ }
- // §S1.3 — absorb any GOAL-directed steer BETWEEN ticks. READ-STAGE-THEN-STAMP (at-least-once
- // advisory delivery, NOT exactly-once): drain (non-consuming read) → stage on the relay so THIS
- // tick's step prompt carries it → run the tick → stamp ONLY what the executor threaded as consumed.
- // A crash after staging but before markSteerConsumed leaves the steer pending → RE-THREADED next
- // iteration (guidance is never lost, but MAY be threaded twice — harmless for advisory steering, and
- // a tick is at-least-once anyway). `pendingSteer` is optional, so a caller wiring only pause/stop (or
- // noopPorts without a relay) behaves exactly as before.
- if (input.steerRelay && ports.pendingSteer) {
- const pending = yield* safeSteers(ports.pendingSteer())
- input.steerRelay.stage(pending)
- }
+ // §S1.3 — absorb any GOAL-directed steer BETWEEN ticks. READ-STAGE-THEN-STAMP (at-least-once advisory
+ // delivery): drain (non-consuming read) → stage on the relay so THIS tick's step prompt carries it →
+ // run the tick → stamp ONLY what the executor threaded as consumed.
+ if (input.steerRelay && ports.pendingSteer) {
+ const pending = yield* safeSteers(ports.pendingSteer())
+ input.steerRelay.stage(pending)
+ }
- last = yield* loop.tick(input.handle)
+ const outcome = yield* loop.tick(input.handle)
- // Consume-after: stamp ONLY the steers the executor actually threaded into the prompt this tick.
- // If the tick short-circuited without running the executor (terminal replay / pre-breach limit),
- // `takeDrained` is empty and nothing is stamped — the steer stays pending for the next real tick.
- if (input.steerRelay && ports.markSteerConsumed) {
- const threaded = input.steerRelay.takeDrained()
- if (threaded.length > 0) yield* safe(ports.markSteerConsumed(threaded.map((s) => s.id)))
- }
+ // Consume-after: stamp ONLY the steers the executor actually threaded into the prompt this tick.
+ if (input.steerRelay && ports.markSteerConsumed) {
+ const threaded = input.steerRelay.takeDrained()
+ if (threaded.length > 0) yield* safe(ports.markSteerConsumed(threaded.map((s) => s.id)))
+ }
+
+ const status = yield* safeStatus(loop, input.handle)
+ if (status) yield* safe(ports.onStatus(status))
+
+ return { outcome, progress: isTerminalOutcome(outcome) ? "terminal" : "continue" }
+ })
- const status = yield* safeStatus(loop, input.handle)
- if (status) yield* safe(ports.onStatus(status))
+export const runToCompletion = (input: RunToCompletionInput): Effect.Effect =>
+ Effect.gen(function* () {
+ const loop = makeGoalLoop(input.deps)
+ const maxIterations = input.maxIterations ?? 10_000
+ let last: TickOutcome = "continue"
- if (isTerminalOutcome(last)) return last
+ for (let i = 0; i < maxIterations; i++) {
+ const step = yield* runOneTick(loop, input)
+ last = step.outcome
+ // stopped / paused / terminal all end the in-process drive loop; only "continue" iterates.
+ if (step.progress !== "continue") return last
}
// Exhausted the driver guard without a terminal outcome — treat as needs_human (never loop forever).
return "needs_human"
diff --git a/packages/deepagent-code/src/session/goal-loop-wiring.ts b/packages/deepagent-code/src/session/goal-loop-wiring.ts
index f409b26e..57540470 100644
--- a/packages/deepagent-code/src/session/goal-loop-wiring.ts
+++ b/packages/deepagent-code/src/session/goal-loop-wiring.ts
@@ -8,6 +8,7 @@ import type {
StepExecutor,
StepExecutorResult,
} from "@deepagent-code/core/deepagent/goal-loop"
+import { budgetNotice } from "@deepagent-code/core/deepagent/goal-loop"
import { SessionV1 } from "@deepagent-code/core/v1/session"
import { ModelV2 } from "@deepagent-code/core/model"
import { ProviderV2 } from "@deepagent-code/core/provider"
@@ -19,6 +20,7 @@ import { DEFAULT_QUORUM_POLICY, type PanelLens, type PanelVerdict } from "../age
import { ReviewResult } from "../agent/schema/orchestration"
import { RuntimeFlags } from "../effect/runtime-flags"
import { SessionPrompt } from "./prompt"
+import { collectVolatileFacts, refreshWorldState } from "./context-ledger"
import { SessionRevert } from "./revert"
import { Session } from "./session"
import { Agent } from "../agent/agent"
@@ -79,6 +81,17 @@ export const highestDiagnosticSeverity = (
return best === null ? null : (LSP_SEVERITY_LABEL[best] ?? "error")
}
+// V4.0.1 P1 §3.3 — render the live LSP diagnostics into a compact World State `diagnostics` slot value:
+// the highest severity present + the count of files with issues. Deliberately terse (a snapshot, not a
+// dump) so the tail stays cheap + byte-stable when diagnostics are unchanged. Empty map ⇒ "clean".
+const renderDiagnosticsSlot = (diagnostics: Record): string => {
+ const filesWithIssues = Object.values(diagnostics).filter((issues) => issues.length > 0).length
+ if (filesWithIssues === 0) return "clean (no diagnostics)"
+ const worst = highestDiagnosticSeverity(diagnostics) ?? "error"
+ const total = Object.values(diagnostics).reduce((n, issues) => n + issues.length, 0)
+ return `highest severity: ${worst}; ${total} issue(s) across ${filesWithIssues} file(s)`
+}
+
// review severity ordering for the reviewer_clean gate (higher = more severe).
const REVIEW_SEVERITY_RANK: Record = { low: 1, medium: 2, high: 3, critical: 4 }
const reviewRank = (s: string): number => REVIEW_SEVERITY_RANK[s.trim().toLowerCase()] ?? 99
@@ -94,8 +107,20 @@ export type SubagentTurnResult = {
readonly structured: unknown | undefined
/** The final text part (free-text turns). */
readonly text: string
- /** input+output+reasoning tokens for this turn (0 when unknown). */
+ /** GROSS input+output+reasoning tokens for this turn (0 when unknown). */
readonly tokensUsed: number
+ /**
+ * V4.0.1 P2 §4.4 — the granular breakdown feeding the goal's NET-token ledger (used only when the goal's
+ * budgetTokenScope is "net"; gross ignores them). `inputTokens` is the FULL billed input (prefix + new
+ * context, cache-adjusted); `outputTokens` is generation (output+reasoning); `carriedPrefixTokens` is
+ * the provider-reported CACHED prefix (cache.read + cache.write) — the best cheaply-available
+ * stable-prefix figure. The net ledger accrues `outputTokens + max(0, inputTokens − carriedPrefixTokens)`
+ * so a long task's ledger no longer inflates linearly from the repeated static prefix each tick. All
+ * default to undefined for stub runners (⇒ the net path falls back to tokensUsed).
+ */
+ readonly inputTokens?: number
+ readonly outputTokens?: number
+ readonly carriedPrefixTokens?: number
/** dollar cost for this turn (0 when unknown). */
readonly cost: number
/**
@@ -147,6 +172,15 @@ export type SubagentTurnInput = {
export type SubagentTurnRunner = (input: SubagentTurnInput) => Effect.Effect
+/**
+ * V4.0.1 P1 §3.3/§3.6 — the per-tick World State provider. Called ONCE at the start of each tick (never
+ * per turn — bounds the git/diagnostics IO cost, §3.3): it collects the latest volatile facts, snapshot-
+ * diffs them into the project's World State doc, and returns the RENDERED tail block (or "" when there is
+ * nothing to inject / on any defect — it is default-safe and never fails the tick). Production wires this
+ * to `collectVolatileFacts` + live LSP diagnostics + `refreshWorldState`; tests inject a stub or omit it.
+ */
+export type WorldStateProvider = () => Effect.Effect
+
// ---------------------------------------------------------------------------------------------------
// GraderPorts builder — assembles the four evaluator ports from the live services + the turn runner.
// Each port lives on the `never` channel (a port must resolve to a concrete result, never fail the
@@ -290,6 +324,20 @@ export const buildStepExecutor = (
* never drains, so nothing is consumed. Omitted ⇒ no goal-tick steering (base behaviour).
*/
steerRelay?: GoalSteerRelay,
+ /**
+ * V4.0.1 P2 §4.4/§4.5 — the `goalBudgetSoftNotify` flag (default ON in production). When true and the
+ * tick's cost has crossed a `softNotifyFractions` tier, `budgetNotice` is threaded into THIS tick's
+ * step-prompt tail (cache-safe — the step prompt IS the child turn's user message). Omitted ⇒ treated as
+ * off (no notice), i.e. pre-V4.0.1 behaviour.
+ */
+ budgetSoftNotify?: boolean,
+ /**
+ * V4.0.1 P1 §3.3 — the per-tick World State provider. When supplied (production with
+ * `worldStateReinjection` ON), it is invoked once at tick start; its rendered block is threaded into the
+ * step-prompt TAIL (before the budget notice). Omitted / undefined ⇒ no World State (base behaviour),
+ * so `worldStateReinjection=false` is byte-for-byte the pre-V4.0.1 prompt.
+ */
+ worldStateProvider?: WorldStateProvider,
): StepExecutor =>
(input) => {
const planBridge = planBridgeFor?.(input.planDocId)
@@ -297,11 +345,20 @@ export const buildStepExecutor = (
// turn's user-message tail via renderStepPrompt, never a system prefix). Draining marks it as
// threaded-this-tick on the relay so the driver stamps exactly these ids consumed after the tick.
const steer = steerRelay ? steerRelay.drainForPrompt() : []
- return runTurn({
- agentType: "goal-worker",
- prompt: renderStepPrompt({ ...input, steer }),
- ...(planBridge ? { prepareSession: (childId: string) => planBridge.seedChildPlan(childId) } : {}),
- }).pipe(
+ // P2 §4.4: compute the tiered COST soft-notice for this tick (gated by goalBudgetSoftNotify). It rides
+ // the step-prompt TAIL (never the prefix), so prompt-cache stability is preserved.
+ const notice = budgetSoftNotify === true ? budgetNotice(input.ledger, input.limits) : null
+ // P1 §3.3: refresh + render the World State ONCE at tick start (default-safe ⇒ "" on any defect). It
+ // rides the tail (before the budget notice) so it reaches the goal-worker every tick (P3(d) recall).
+ return (worldStateProvider ? worldStateProvider() : Effect.succeed("")).pipe(
+ Effect.flatMap((worldState) =>
+ runTurn({
+ agentType: "goal-worker",
+ prompt: renderStepPrompt({ ...input, steer, budgetNotice: notice, worldState }),
+ ...(planBridge ? { prepareSession: (childId: string) => planBridge.seedChildPlan(childId) } : {}),
+ }),
+ ),
+ ).pipe(
Effect.map((turn): StepExecutorResult => {
// Mirror the worker's plan-state back into the goal plan doc AFTER the turn (best-effort; a
// bridge defect must not fail the tick — the grader simply sees no plan advance and the loop
@@ -315,6 +372,11 @@ export const buildStepExecutor = (
}
return {
tokensUsed: turn.tokensUsed,
+ // P2 §4.4: surface the granular breakdown so the loop's NET ledger (budgetTokenScope "net") can
+ // subtract the carried (cached) prefix. Harmless under "gross" (ignored there).
+ ...(turn.inputTokens != null ? { inputTokens: turn.inputTokens } : {}),
+ ...(turn.outputTokens != null ? { outputTokens: turn.outputTokens } : {}),
+ ...(turn.carriedPrefixTokens != null ? { carriedPrefixTokens: turn.carriedPrefixTokens } : {}),
cost: turn.cost,
// Surface the CHILD session the turn ran in so a critical-failure rollback reverts THAT
// session (where the file edits live), not the parent goal session (which has none). Without
@@ -382,6 +444,21 @@ export const renderStepPrompt = (input: {
readonly activeStepId: string | null
/** §S1.3 — mid-run steering drained from the goal session's steer buffer, threaded into this turn. */
readonly steer?: ReadonlyArray
+ /**
+ * V4.0.1 P2 §4.4 — the tiered COST soft-notice for this tick (or null). Appended to the TAIL of the
+ * step prompt (never the prefix), so it lands in the model INPUT tail like the steering block and never
+ * perturbs any cached system prefix. It is a converge nudge, NOT a stop signal — the loop keeps ticking.
+ */
+ readonly budgetNotice?: string | null
+ /**
+ * V4.0.1 P1 §3.3 — the rendered World State block (latest volatile facts: open files / git /
+ * diagnostics / env). Injected into the step-prompt TAIL so it reaches the goal-worker EVERY tick
+ * (P3(d) goal-worker recall — this is the gate-free channel that bypasses shouldLoadBridge's general
+ * short-circuit by construction). Placed AFTER the advance instruction but BEFORE the budget notice:
+ * World State is larger + more stable (snapshot-diff byte-stable across ticks), the budget notice is
+ * short + volatile, so keeping the most volatile content LAST preserves near-end prompt-cache stability.
+ */
+ readonly worldState?: string | null
}): string => {
const steerLines =
input.steer && input.steer.length > 0
@@ -392,6 +469,13 @@ export const renderStepPrompt = (input: {
``,
]
: []
+ // P1 §3.3: the World State block rides the TAIL after the advance instruction. It is snapshot-diff
+ // byte-stable across ticks, so it sits BEFORE the (short/volatile) budget notice to keep the most
+ // volatile content last (near-end prompt-cache stability).
+ const worldStateLines = input.worldState && input.worldState.trim() ? [``, input.worldState.trim()] : []
+ // P2 §4.4: the budget notice rides the TAIL, after the fixed advance instruction, so it perturbs
+ // neither the cached system prefix nor the (also cache-relevant) leading instruction lines.
+ const budgetLines = input.budgetNotice ? [``, `BUDGET NOTICE: ${input.budgetNotice}`] : []
return [
...steerLines,
`Advance goal ${input.goalId}. Execute exactly ONE plan step of real progress this turn.`,
@@ -399,6 +483,8 @@ export const renderStepPrompt = (input: {
? `The active step is "${input.activeStepId}". Complete it, then mark it done and set the next step active.`
: `No step is currently active. Read the plan, pick the next pending step, mark it active, and make progress.`,
`Ground every "done" in a verifiable fact (a command you ran, a test that passed). Do NOT mark a step done to satisfy the gate.`,
+ ...worldStateLines,
+ ...budgetLines,
].join("\n")
}
@@ -487,12 +573,40 @@ export const makeTaskSubagentRunner = (deps: TaskSubagentRunnerDeps): SubagentTu
const structured =
info.role === "assistant" && input.outputSchema ? (info.structured as unknown | undefined) : undefined
const text = result.parts.findLast((p) => p.type === "text")?.text ?? ""
+ // GROSS throughput (input+output+reasoning) — the pre-V4.0.1 figure, always populated.
const tokens =
info.role === "assistant"
? Math.max(0, (info.tokens.input ?? 0) + (info.tokens.output ?? 0) + (info.tokens.reasoning ?? 0))
: 0
+ // V4.0.1 P2 §4.4 — the granular breakdown for the goal's NET-token ledger (used only under
+ // budgetTokenScope "net"). `info.tokens.input` is already the cache-ADJUSTED (non-cached) input in
+ // this codebase (session.ts:437 subtracts cache.read/write from the SDK's folded inputTokens), and
+ // `cache.read + cache.write` is the provider-reported CACHED prefix — the best cheaply-available
+ // stable-prefix figure for carriedPrefixTokens. The net ledger then accrues
+ // `output(+reasoning) + max(0, (input+cachedPrefix) − cachedPrefix)`, i.e. it charges only the
+ // non-cached input delta above the repeated stable prefix. `inputTokens` is reported as the FULL
+ // billed input (non-cached input + cached prefix) so the core subtraction is symmetric; on a cache
+ // miss (cache.read=0) carriedPrefixTokens is 0 and the full input counts (correct + monotonic).
+ const inputFull =
+ info.role === "assistant"
+ ? Math.max(0, (info.tokens.input ?? 0) + (info.tokens.cache?.read ?? 0) + (info.tokens.cache?.write ?? 0))
+ : 0
+ const outputNet =
+ info.role === "assistant" ? Math.max(0, (info.tokens.output ?? 0) + (info.tokens.reasoning ?? 0)) : 0
+ const carriedPrefix =
+ info.role === "assistant" ? Math.max(0, (info.tokens.cache?.read ?? 0) + (info.tokens.cache?.write ?? 0)) : 0
const cost = info.role === "assistant" && Number.isFinite(info.cost) ? info.cost : 0
- return { ok: true, structured, text, tokensUsed: tokens, cost, sessionID: child.id } satisfies SubagentTurnResult
+ return {
+ ok: true,
+ structured,
+ text,
+ tokensUsed: tokens,
+ inputTokens: inputFull,
+ outputTokens: outputNet,
+ carriedPrefixTokens: carriedPrefix,
+ cost,
+ sessionID: child.id,
+ } satisfies SubagentTurnResult
}).pipe(Effect.catchCause(() => Effect.succeed(failedTurn("subagent turn failed"))))
const failedTurn = (_reason: string): SubagentTurnResult => ({
@@ -577,13 +691,45 @@ export const makeGoalLoopWiring = (
const planBridgeFor = (planDocId: string): PlanBridge =>
makePlanBridge({ store: input.store, planDocId, agentMode: input.agentMode ?? "general" })
+ // P1 §3.3 + P3(d): the per-tick World State provider, gated by worldStateReinjection. It collects the
+ // cheap volatile facts (git + env) + the live LSP diagnostics summary, snapshot-diffs them into the
+ // project's World State doc, and renders the tail block. This is the GATE-FREE goal-worker recall path
+ // (P3(d)): it reaches the goal-worker via the step-prompt tail regardless of shouldLoadBridge's general
+ // short-circuit, WITHOUT flipping bridge.ts:117 (which would leak the handoff to all general subagents).
+ // Undefined when the flag is OFF ⇒ the executor threads no World State (byte-for-byte pre-V4.0.1).
+ const worldStateProvider: WorldStateProvider | undefined = flags.worldStateReinjection
+ ? () =>
+ Effect.gen(function* () {
+ const facts = yield* collectVolatileFacts(input.cwd)
+ const diag = yield* input
+ .diagnostics()
+ .pipe(Effect.catchCause(() => Effect.succeed({ diagnostics: {}, checked: false })))
+ const diagText = diag.checked ? renderDiagnosticsSlot(diag.diagnostics) : undefined
+ return yield* refreshWorldState({
+ workspacePath: input.cwd,
+ facts: { ...facts, ...(diagText != null ? { diagnostics: diagText } : {}) },
+ })
+ })
+ : undefined
+
return {
store: input.store,
ports,
// §S1.3: thread the shared goal-steer relay so each tick's step prompt carries staged user guidance.
- executor: buildStepExecutor(input.runTurn, planBridgeFor, input.steerRelay),
+ // P2 §4.4: pass goalBudgetSoftNotify so the executor threads a tiered cost soft-notice into the tail.
+ // P1 §3.3: pass the World State provider so each tick re-injects the latest volatile facts (tail).
+ executor: buildStepExecutor(
+ input.runTurn,
+ planBridgeFor,
+ input.steerRelay,
+ flags.goalBudgetSoftNotify,
+ worldStateProvider,
+ ),
rollback: input.rollback,
now: input.now ?? (() => Date.now()),
+ // P2 §4.5: stamp the goal's token-accounting convention from goalNetTokenBudget at creation (start()
+ // reads this once; tick() thereafter obeys the persisted budgetTokenScope marker, never the flag).
+ netTokenBudget: flags.goalNetTokenBudget,
} satisfies ControllerDeps
})
diff --git a/packages/deepagent-code/src/session/goal-manager.ts b/packages/deepagent-code/src/session/goal-manager.ts
index cb36dbe4..244c2ebd 100644
--- a/packages/deepagent-code/src/session/goal-manager.ts
+++ b/packages/deepagent-code/src/session/goal-manager.ts
@@ -34,6 +34,9 @@ import { writeGovernanceAudit } from "./goal-governance-audit"
import { DeepAgentEventBus } from "@deepagent-code/core/deepagent/deepagent-event-bus"
import { ApprovalQueue } from "@deepagent-code/core/deepagent/approval-queue"
import { LMNEvents } from "@deepagent-code/core/deepagent/lmn-events"
+import { persistPendingPlanEdit, readGoalTickCursor } from "@deepagent-code/core/deepagent/goal-loop"
+import { makeGoalStatusPublisher } from "./goal-status-publisher"
+import { GoalTickConsumer } from "./goal-tick-consumer"
/**
* V3.9 §D — the GOAL MANAGER service: the resident, in-process supervisor that OWNS running goals.
@@ -53,7 +56,7 @@ import { LMNEvents } from "@deepagent-code/core/deepagent/lmn-events"
// The DocumentStore holding a session's goal docs. Co-located with the run graph under the agent data
// root, keyed by session id, so a restart re-opens the same store (the loop state is restart-recoverable).
-const goalStoreRoot = (sessionID: string): string =>
+export const goalStoreRoot = (sessionID: string): string =>
path.join(Global.Path.agent.data, "state", "goal", sessionID, "graph")
const glog = Log.create({ service: "session.goal" })
@@ -290,180 +293,28 @@ export const layer = Layer.effect(
return m
})
- // 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)
-
- // §S2 — mirror the goal's plan doc INTO the parent session's live plan state + emit plan.updated, so
- // the client's session_plan reflects the running goal's progress tick-by-tick. Without this the parent
- // session_plan is frozen at goal-start (the worker's plan edits land on its CHILD session + the goal
- // doc, never republished here), so the plan-edit dialog would pre-fill from STALE data and a save would
- // regress live progress (statuses would be reset to whatever the frozen snapshot showed). Reading the
- // goal doc — the single source of truth the grader uses — keeps the UI and the edit pre-fill honest.
- // Best-effort: a read/publish failure must never break the tick.
- const mirrorGoalPlanToSession = (sessionID: string, planDocId: string) =>
- Effect.gen(function* () {
- const store = new DocumentStore(goalStoreRoot(sessionID))
- const doc = store.get(planDocId)
- if (!doc) return
- let plan: PlanDoc
- try {
- plan = JSON.parse(doc.body) as PlanDoc
- } catch {
- return
- }
- // Keep the parent session's in-memory plan-state live (the dialog pre-fills from this).
- AgentGateway.DeepAgentSessionState.setPlan(sessionID, plan as never)
- const { done, total } = AgentGateway.DeepAgentPlanController.planProgress(plan)
- yield* events
- .publish(PlanEvent.Updated, {
- sessionID: SessionID.make(sessionID),
- plan_id: plan.plan_id,
- goal: plan.goal,
- active_step_id: plan.active_step_id,
- steps: plan.steps.map((s) => ({
- step_id: s.step_id,
- title: s.title,
- status: s.status,
- acceptance: s.acceptance ?? null,
- assigned_agent: s.assigned_agent ?? null,
- note: s.note ?? null,
- })),
- done,
- total,
- })
- .pipe(Effect.ignore)
- }).pipe(Effect.catchCause(() => Effect.void))
-
- // 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)
- // Keep the parent session_plan live with the goal's plan-doc progress (see helper doc).
- yield* mirrorGoalPlanToSession(sessionID, status.planDocId)
- 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,
- })
- // V4.0 §N — mirror the goal lifecycle onto the DeepAgent Event Bus (and escalations into the
- // §D2 Approval Queue). Flag-gated: OFF (default) ⇒ this whole block is skipped and the V3.9
- // goal.updated path above is unchanged. Best-effort: a bus/queue failure never breaks the loop.
- if (flags.v4MultiAgentRuntime) {
- yield* emitGoalLifecycleEvent(sessionID, status, phase).pipe(
- Effect.catchCause(() => Effect.void),
- )
- }
- })
-
- // §N — publish the discrete goal lifecycle event (goal.tick for a running tick, or the terminal
- // type) and, for a terminal escalation (needs_human / rolled_back), offer it to the Approval Queue.
- // The workspace key is the session's directory (matches how the Oversight surface scopes).
- const emitGoalLifecycleEvent = (sessionID: string, status: GoalStatus, phase: string) =>
- Effect.gen(function* () {
- const session = yield* sessions.get(SessionID.make(sessionID)).pipe(Effect.orElseSucceed(() => undefined))
- // workspace key MUST mirror the Oversight read side, else an escalation written here is keyed on
- // one identity while GET /oversight/approvals reads by another → invisible on the Dashboard in
- // server edition. Both sides derive the key via the SINGLE canonical rule (ApprovalQueue.
- // deriveWorkspaceKey): a genuine wrk_ workspaceID wins, else the directory, with sessionID as the
- // last-resort fallback so a key is always produced.
- const workspaceID = ApprovalQueue.deriveWorkspaceKey({
- workspaceID: session?.workspaceID,
- directory: session?.directory,
- fallback: sessionID,
- })
- // map the driver phase → the discrete §N event type (running/paused/stopped ⇒ goal.tick).
- const eventType = LMNEvents.goalPhaseToEventType(phase) ?? LMNEvents.GOAL_TICK
- // idempotencyKey reuses the V3.9 plan-version idempotency intent: one event per (goal, phase,
- // tick) so a re-published status doesn't double-emit.
- const idempotencyKey = `goal:${status.goalId}:${phase}:${status.ledger.ticks}`
- // §E2 RATE GATE + §D2 no-silent-loss: any event that must reach the Approval Queue MUST publish
- // at "high" so it BYPASSES the per-workspace ceiling and always persists + offers. This is NOT
- // just goal.needs_human — goal.rolled_back is also a terminal APPROVAL_QUEUE_TYPES member, and
- // because the publish limiter is shared per-workspace it could otherwise be shed by an unrelated
- // im.message.created flood in the same minute → a rollback needing human review silently lost.
- // `isApprovalQueueCandidate` folds the full APPROVAL_QUEUE_TYPES set; goal.tick / goal.completed
- // are NOT candidates, stay "normal", and remain correctly sheddable under load.
- const priority = LMNEvents.isApprovalQueueCandidate(eventType) ? "high" : "normal"
- // §E2 RATE GATE (live): the goal driver is a workspace-facing publisher (one event per tick),
- // so it goes through `tryPublish` under the 1000/min per-workspace ceiling. A `goal.tick` /
- // `goal.completed` is `normal` and CAN be shed under a flood; an approval-queue candidate
- // (needs_human / rolled_back) is `high` and ALWAYS bypasses the gate — never dropped. On a drop
- // we skip the approval offer (there is no persisted event to queue) and record §A4 event_dropped.
- const outcome = yield* eventBus.tryPublish({
- type: eventType,
- source: "system",
- workspaceID,
- actorID: sessionID,
- correlationID: status.goalId,
- idempotencyKey,
- priority,
- // T2.4 archive contract: goal.completed is an ARCHIVE_TRIGGER, and the EventDrivenArchiver
- // discards any trigger whose payload lacks sessionID + workspacePath (see
- // event-driven-archiver.ts). Carry both (workspacePath = the session directory, mirroring
- // session-completed-publisher's `facts.directory`) so a completed goal is actually archived
- // instead of being silently dropped at the archiver. Harmless on non-archive phases.
- payload: {
- goalId: status.goalId,
- planDocId: status.planDocId,
- phase,
- gaps: status.gaps,
- sessionID,
- workspacePath: session?.directory,
- },
- })
- if ("dropped" in outcome) {
- yield* Effect.logWarning("goal lifecycle event dropped by publish rate gate").pipe(
- Effect.annotateLogs({
- reason: "event_dropped",
- cause: "rate_limited",
- workspaceID,
- goalId: status.goalId,
- phase,
- }),
- )
- return
- }
- // terminal escalations queue for human review (§D2). shouldQueueForApproval gates it.
- yield* approvalQueue.offer(outcome.published)
- })
+ // V4.1 §N — the SHARED status publisher. publishGoalEvent (raw goal.updated) + publishStatus (the
+ // full onStatus port: mirror plan → session-state, publish goal.updated, flag-on mirror to bus +
+ // approval queue). Extracted to goal-status-publisher.ts so the event-driven cold consumer's port
+ // (makeGoalTickPort) runs the IDENTICAL logic — no drift. The only goal-manager-specific step is
+ // caching the tick's ledger on the in-memory control (so pause/resume/stop publish the real ledger);
+ // that rides the optional `cacheStatus` callback here and is simply omitted on the cold path.
+ const statusPublisher = makeGoalStatusPublisher({
+ events,
+ sessions,
+ eventBus,
+ approvalQueue,
+ v4MultiAgentRuntime: flags.v4MultiAgentRuntime,
+ goalStoreRoot,
+ cacheStatus: (sessionID, cached) =>
+ mutateControl(sessionID, (ctrl) => {
+ ctrl.ledger = cached.ledger
+ ctrl.stallCount = cached.stallCount
+ ctrl.gaps = cached.gaps
+ }),
+ })
+ const publishGoalEvent = statusPublisher.publishGoalEvent
+ const publishStatus = statusPublisher.publishStatus
// 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.
@@ -652,24 +503,55 @@ export const layer = Layer.effect(
markPlanEditConsumed: planEditPort.markPlanEditConsumed,
}
- // Start the driver as a resident background task. onFinish clears the pointer / control state.
- const job = yield* background.start({
- type: "goal-loop",
- title: `goal ${handle.goalId}`,
- metadata: { sessionID, goalId: handle.goalId },
- run: GoalDriver.runToCompletion({ deps, handle, ports, steerRelay }).pipe(
- // 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`)),
- ),
- })
+ // V4.1 §N DUAL-PATH drive. The two paths MUST be mutually exclusive — running both for one goal
+ // would double-execute every tick over the same run_context doc.
+ // • flag ON (v4MultiAgentRuntime): publish the FIRST goal.tick.requested command onto the Event
+ // Bus (seq=0). The GoalTickConsumer executes each tick on a (possibly cold) fiber and re-emits
+ // the next command — the self-driving, persistence/retry/dedup-backed chain (contract §N). NO
+ // BackgroundJob is started, so there is exactly one driver.
+ // • flag OFF (default): the existing in-process BackgroundJob `runToCompletion` drives the ticks
+ // (byte-identical to V3.9). The event-driven consumer's handle() acks-and-drives-nothing when
+ // the flag is off, so a stray command can never double-drive.
+ // In BOTH paths we still register the in-memory control (jobId="" on the event path — there is no
+ // job to cancel; stop() sets the durable phase the cold tick reads, and cancel("") is a safe no-op)
+ // so pause / resume / stop / status / editPlan keep working the same way.
+ let jobId = ""
+ if (flags.v4MultiAgentRuntime) {
+ // seq=0 is the seed; expectedPlanVersion is advisory trace. The bus dedups on goal:tick::0
+ // so a retried start() cannot seed two chains.
+ const expectedPlanVersion = store.get(planDocId)?.version ?? 0
+ yield* eventBus
+ .publish(
+ GoalTickConsumer.tickCommand({
+ sessionID,
+ goalId: handle.goalId,
+ planDocId: handle.planDocId,
+ seq: 0,
+ expectedPlanVersion,
+ }),
+ )
+ .pipe(Effect.ignore)
+ } else {
+ // Start the driver as a resident background task. onFinish clears the pointer / control state.
+ const job = yield* background.start({
+ type: "goal-loop",
+ title: `goal ${handle.goalId}`,
+ metadata: { sessionID, goalId: handle.goalId },
+ run: GoalDriver.runToCompletion({ deps, handle, ports, steerRelay }).pipe(
+ // 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`)),
+ ),
+ })
+ jobId = job.id
+ }
yield* setControl(sessionID, {
goalId: handle.goalId,
planDocId: handle.planDocId,
- jobId: job.id,
+ jobId,
paused: false,
stopped: false,
ledger: { ticks: 0, tokens: 0, cost: 0, wallclockMs: 0 },
@@ -706,6 +588,29 @@ export const layer = Layer.effect(
// Re-drive: the persisted run_context doc resumes exactly where it paused. A fresh store handle
// over the same root re-reads the loop state.
const store = new DocumentStore(goalStoreRoot(sessionID))
+
+ // V4.1 §N DUAL-PATH resume. flag ON ⇒ RE-SEED the event chain rather than starting a BackgroundJob.
+ // The paused command consumed the current normal cursor key without advancing state. Resume keeps
+ // that cursor as the tick's true pre-state but publishes it through a fresh resume-only key; the
+ // resumed tick's successor returns to the normal durable-cursor namespace.
+ if (flags.v4MultiAgentRuntime) {
+ const tick = readGoalTickCursor(store, sessionID, c.goalId)
+ const expectedPlanVersion = tick?.planVersion ?? store.get(c.planDocId)?.version ?? 0
+ yield* eventBus
+ .publish(
+ GoalTickConsumer.resumeTickCommand({
+ sessionID,
+ goalId: c.goalId,
+ planDocId: c.planDocId,
+ seq: tick?.seq ?? 0,
+ expectedPlanVersion,
+ }),
+ )
+ .pipe(Effect.ignore)
+ yield* publishControlPhase(sessionID, c, "running")
+ return true
+ }
+
const session = yield* sessions.get(SessionID.make(sessionID)).pipe(Effect.orDie)
// Resume can't run without a model; if none resolves, don't crash — just report "not resumed".
const modelOpt = yield* resolveGoalModel(session).pipe(Effect.option)
@@ -831,6 +736,17 @@ export const layer = Layer.effect(
next.set(input.sessionID, { ...ctrl, pendingPlanEdit: input.plan })
return next
})
+ // V4.1 cross-process cold recovery — ALSO persist the pending edit to the durable doc (under the
+ // SAME store root the cold GoalTickConsumer opens). The in-memory control slot above serves the
+ // WARM in-process driver (which holds a live store handle); the durable doc serves a COLD
+ // event-driven tick that reconstructs the wiring with a fresh store handle and reads the edit via
+ // readPendingPlanEdit. Best-effort: a durable-write hiccup must not fail editPlan (the warm path
+ // still has the in-memory slot). Cleared by the driver post-apply via persistPendingPlanEdit(null).
+ try {
+ persistPendingPlanEdit(new DocumentStore(goalStoreRoot(input.sessionID)), input.sessionID, c.goalId, input.plan)
+ } catch {
+ /* durable persist is best-effort; the warm in-memory slot above is authoritative for the live driver */
+ }
// Audit + operational log the human plan edit (enqueue-time; the driver applies it next tick).
writeGovernanceAudit(input.sessionID, c.goalId, "plan_edit", {
stepCount: input.plan.steps.length,
diff --git a/packages/deepagent-code/src/session/goal-status-publisher.ts b/packages/deepagent-code/src/session/goal-status-publisher.ts
new file mode 100644
index 00000000..2a05b32c
--- /dev/null
+++ b/packages/deepagent-code/src/session/goal-status-publisher.ts
@@ -0,0 +1,200 @@
+export * as GoalStatusPublisher from "./goal-status-publisher"
+
+import { Effect } from "effect"
+import { AgentGateway } from "@deepagent-code/core/agent-gateway"
+import { DocumentStore } from "@deepagent-code/core/deepagent/document-store"
+import type { PlanDoc } from "@deepagent-code/core/deepagent/plan-controller"
+import type { GoalStatus } from "@deepagent-code/core/deepagent/goal-loop"
+import { DeepAgentEventBus } from "@deepagent-code/core/deepagent/deepagent-event-bus"
+import { ApprovalQueue } from "@deepagent-code/core/deepagent/approval-queue"
+import { LMNEvents } from "@deepagent-code/core/deepagent/lmn-events"
+import type { EventV2 } from "@deepagent-code/core/event"
+import type { Session } from "./session"
+import { SessionID } from "./schema"
+import { GoalEvent } from "./goal-event"
+import { PlanEvent } from "../tool/plan-write"
+
+/**
+ * V4.1 §N — the SHARED goal-status PUBLISHER. Historically `publishStatus` / `emitGoalLifecycleEvent` /
+ * `mirrorGoalPlanToSession` lived as closures inside `goal-manager.ts`'s `layer`. When the event-driven
+ * GoalTickConsumer's production port (`makeGoalTickPort`) reconstructs the goal wiring on a COLD fiber, it
+ * needs the SAME onStatus behaviour — mirror the plan into session-state, publish `goal.updated` over the
+ * SSE bridge, and (flag-on) mirror the lifecycle onto the Event Bus + Approval Queue. Duplicating that
+ * closure in two places would drift (e.g. one path forgets the approval-queue escalation). This module is
+ * the single implementation both drivers call, parameterized by injected services.
+ *
+ * The ONLY goal-manager-specific behaviour is caching the last-known ledger on the in-memory `controls`
+ * map (so a pause/resume/stop can publish the real ledger immediately). The cold consumer has no such map,
+ * so that step is an optional `cacheStatus` callback — omitted on the cold path, provided by goal-manager.
+ *
+ * The store-root resolver is INJECTED (`goalStoreRoot`) so this module has no path opinion — both callers
+ * pass goal-manager's canonical `goalStoreRoot` so the cold consumer opens a fresh store over the SAME path.
+ */
+
+export type GoalStatusPublisherDeps = {
+ /** The SSE bridge (goal.updated / plan.updated) — production: EventV2Bridge; cold path: same. */
+ readonly events: EventV2.Interface
+ /** Session lookup (to derive the Approval-Queue workspace key from directory/workspaceID). */
+ readonly sessions: Session.Interface
+ /** The DeepAgent Event Bus — the §N lifecycle mirror + approval-queue escalation ride it. */
+ readonly eventBus: DeepAgentEventBus.Interface
+ /** The §D2 Approval Queue — a terminal escalation (needs_human / rolled_back) offers into it. */
+ readonly approvalQueue: ApprovalQueue.Interface
+ /** Whether the V4 event-driven layer is on (gates the bus + approval-queue mirror; default V3.9 path is unchanged). */
+ readonly v4MultiAgentRuntime: boolean
+ /** The store-root resolver for the goal's plan doc (mirrorGoalPlanToSession reads it). */
+ readonly goalStoreRoot: (sessionID: string) => string
+ /**
+ * Optional: cache the tick's ledger/stall/gaps somewhere the caller controls (goal-manager's in-memory
+ * control map). Called on every status BEFORE publishing. Omitted on the cold consumer path (no map).
+ */
+ readonly cacheStatus?: (
+ sessionID: string,
+ cached: { ledger: { ticks: number; tokens: number; cost: number; wallclockMs: number }; stallCount: number; gaps: readonly string[] },
+ ) => Effect.Effect
+}
+
+export type GoalStatusPublisher = {
+ /** Emit a raw goal.updated (used for start-seed + control transitions). Best-effort. */
+ readonly publishGoalEvent: (
+ sessionID: string,
+ payload: {
+ goalId: string
+ planDocId: string
+ phase: string
+ ledger: { ticks: number; tokens: number; cost: number; wallclockMs: number }
+ stallCount: number
+ gaps: readonly string[]
+ },
+ ) => Effect.Effect
+ /** The full onStatus port: mirror plan → session-state, publish goal.updated, (flag-on) mirror to bus + approval queue. */
+ readonly publishStatus: (sessionID: string, status: GoalStatus) => Effect.Effect
+}
+
+export const makeGoalStatusPublisher = (deps: GoalStatusPublisherDeps): GoalStatusPublisher => {
+ // 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/cold driver tick).
+ const publishGoalEvent: GoalStatusPublisher["publishGoalEvent"] = (sessionID, payload) =>
+ deps.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)
+
+ // Mirror the goal's plan doc INTO the parent session's live plan state + emit plan.updated, so the
+ // client's session_plan reflects the running goal's progress tick-by-tick. Best-effort.
+ const mirrorGoalPlanToSession = (sessionID: string, planDocId: string) =>
+ Effect.gen(function* () {
+ const store = new DocumentStore(deps.goalStoreRoot(sessionID))
+ const doc = store.get(planDocId)
+ if (!doc) return
+ let plan: PlanDoc
+ try {
+ plan = JSON.parse(doc.body) as PlanDoc
+ } catch {
+ return
+ }
+ AgentGateway.DeepAgentSessionState.setPlan(sessionID, plan as never)
+ const { done, total } = AgentGateway.DeepAgentPlanController.planProgress(plan)
+ yield* deps.events
+ .publish(PlanEvent.Updated, {
+ sessionID: SessionID.make(sessionID),
+ plan_id: plan.plan_id,
+ goal: plan.goal,
+ active_step_id: plan.active_step_id,
+ steps: plan.steps.map((s) => ({
+ step_id: s.step_id,
+ title: s.title,
+ status: s.status,
+ acceptance: s.acceptance ?? null,
+ assigned_agent: s.assigned_agent ?? null,
+ note: s.note ?? null,
+ })),
+ done,
+ total,
+ })
+ .pipe(Effect.ignore)
+ }).pipe(Effect.catchCause(() => Effect.void))
+
+ // §N — publish the discrete goal lifecycle event (goal.tick for a running tick, or the terminal type)
+ // and, for a terminal escalation (needs_human / rolled_back), offer it to the Approval Queue.
+ const emitGoalLifecycleEvent = (sessionID: string, status: GoalStatus, phase: string) =>
+ Effect.gen(function* () {
+ const session = yield* deps.sessions.get(SessionID.make(sessionID)).pipe(Effect.orElseSucceed(() => undefined))
+ const workspaceID = ApprovalQueue.deriveWorkspaceKey({
+ workspaceID: session?.workspaceID,
+ directory: session?.directory,
+ fallback: sessionID,
+ })
+ const eventType = LMNEvents.goalPhaseToEventType(phase) ?? LMNEvents.GOAL_TICK
+ const idempotencyKey = `goal:${status.goalId}:${phase}:${status.ledger.ticks}`
+ const priority = LMNEvents.isApprovalQueueCandidate(eventType) ? "high" : "normal"
+ const outcome = yield* deps.eventBus.tryPublish({
+ type: eventType,
+ source: "system",
+ workspaceID,
+ actorID: sessionID,
+ correlationID: status.goalId,
+ idempotencyKey,
+ priority,
+ payload: {
+ goalId: status.goalId,
+ planDocId: status.planDocId,
+ phase,
+ gaps: status.gaps,
+ sessionID,
+ workspacePath: session?.directory,
+ },
+ })
+ if ("dropped" in outcome) {
+ yield* Effect.logWarning("goal lifecycle event dropped by publish rate gate").pipe(
+ Effect.annotateLogs({
+ reason: "event_dropped",
+ cause: "rate_limited",
+ workspaceID,
+ goalId: status.goalId,
+ phase,
+ }),
+ )
+ return
+ }
+ yield* deps.approvalQueue.offer(outcome.published)
+ })
+
+ // Publish a driver status → the goal.updated event, the session-state active-goal pointer, AND (via the
+ // optional cacheStatus) the caller's cached last-known status.
+ const publishStatus: GoalStatusPublisher["publishStatus"] = (sessionID, status) =>
+ Effect.gen(function* () {
+ const phase = status.phase as string
+ AgentGateway.DeepAgentSessionState.setActiveGoalPhase(sessionID, phase as never)
+ yield* mirrorGoalPlanToSession(sessionID, status.planDocId)
+ const ledger = {
+ ticks: status.ledger.ticks,
+ tokens: status.ledger.tokens,
+ cost: status.ledger.cost,
+ wallclockMs: status.ledger.wallclockMs,
+ }
+ if (deps.cacheStatus) {
+ yield* deps.cacheStatus(sessionID, { ledger, stallCount: status.stallCount, gaps: status.gaps })
+ }
+ yield* publishGoalEvent(sessionID, {
+ goalId: status.goalId,
+ planDocId: status.planDocId,
+ phase,
+ ledger,
+ stallCount: status.stallCount,
+ gaps: status.gaps,
+ })
+ if (deps.v4MultiAgentRuntime) {
+ yield* emitGoalLifecycleEvent(sessionID, status, phase).pipe(Effect.catchCause(() => Effect.void))
+ }
+ })
+
+ return { publishGoalEvent, publishStatus }
+}
diff --git a/packages/deepagent-code/src/session/goal-tick-consumer.ts b/packages/deepagent-code/src/session/goal-tick-consumer.ts
new file mode 100644
index 00000000..6b238913
--- /dev/null
+++ b/packages/deepagent-code/src/session/goal-tick-consumer.ts
@@ -0,0 +1,260 @@
+export * as GoalTickConsumer from "./goal-tick-consumer"
+
+import { Context, Effect, Layer, Stream, Schedule, Duration, Cause } from "effect"
+import { DeepAgentEventBus } from "@deepagent-code/core/deepagent/deepagent-event-bus"
+import { DeepAgentEvent } from "@deepagent-code/core/deepagent/deepagent-event"
+import { LMNEvents } from "@deepagent-code/core/deepagent/lmn-events"
+import { RuntimeFlags } from "@/effect/runtime-flags"
+import * as Log from "@deepagent-code/core/util/log"
+
+// V4.1 §N — the GOAL TICK CONSUMER: the piece that makes the goal-loop tick GENUINELY event-driven.
+//
+// The V4.0 contract (§N) says "tick = goal.tick event" with persistence/retry/dedup, but historically a
+// tick ran in an in-process for-loop (goal-driver runToCompletion) and `goal.tick` was only an
+// after-the-fact TRACE with no consumer. This service closes that gap: it consumes the durable COMMAND
+// `goal.tick.requested`, executes EXACTLY ONE tick (via an injected `runTick` port), and — while the goal
+// is non-terminal — re-emits the next `goal.tick.requested` (the self-driving chain). Because the command
+// rides the Event Bus, the tick inherits persistence, at-least-once delivery, retry-with-backoff, and
+// dedup; a nack retries the REAL tick, not a breadcrumb.
+//
+// COLD RECOVERY: the production `runTick` port (makeGoalTickPort, wired separately) reconstructs the
+// entire goal wiring from durable state + the event payload on a COLD fiber (no in-memory control map),
+// so a goal survives a process restart and resumes from the durable run_context doc. This service itself
+// holds NO per-goal state — it is a stateless bus consumer, exactly like SupervisorNotifier.
+//
+// IDEMPOTENCY: normal commands key on the strictly monotonic durable cursor. Resume seeds use a separate
+// one-shot namespace because the command that observed a pause already consumed the current cursor without
+// advancing state. The production port compares request.seq with durable state before executing, so a
+// delivery retried after its tick persisted only repairs the successor and never runs a second tick.
+//
+// FLAG-GATED: v4MultiAgentRuntime (the event-driven layer master flag). Off ⇒ handle() still ACKS every
+// delivery (discharges the durable row) but drives nothing — the in-process BackgroundJob driver
+// (goal-manager, flag-OFF path) is authoritative.
+
+const log = Log.create({ service: "goal-tick-consumer" })
+
+// The durable consumer group this service reads under (§A3 at-least-once: publish records a pending
+// delivery row per owed event for this group, so a crash mid-handle is recoverable via dueRetries).
+export const TICK_GROUP = "goal-tick-consumer"
+
+const DEFAULT_RETRY_PUMP_INTERVAL_MS = 30_000
+
+// The command payload carried by a goal.tick.requested event. `seq` is the dedup identity;
+// `expectedPlanVersion` is advisory (trace + sanity-check).
+export type GoalTickRequest = {
+ readonly sessionID: string
+ readonly goalId: string
+ readonly planDocId: string
+ readonly seq: number
+ readonly expectedPlanVersion: number
+}
+
+// What the injected runTick port returns after executing ONE tick. `progress` mirrors the driver's
+// OneTickResult.progress; `nextSeq`/`nextExpectedPlanVersion` are read from the POST-tick durable state
+// so the consumer can construct the next command deterministically.
+export type GoalTickPortResult = {
+ readonly progress: "stopped" | "paused" | "terminal" | "continue"
+ readonly nextSeq: number
+ readonly nextExpectedPlanVersion: number
+}
+
+// The injected execution port. Production wires makeGoalTickPort (cold reconstruction + runOneTick);
+// tests wire a deterministic stub. Lives on `never` — a defect is caught by the consumer and nacked.
+export type GoalTickPort = (request: GoalTickRequest) => Effect.Effect
+
+// Parse the event payload into a GoalTickRequest. Returns null when the payload is not a well-formed
+// goal.tick.requested (defensive: a malformed command is acked-and-dropped rather than nacked forever).
+const parseRequest = (payload: unknown): GoalTickRequest | null => {
+ if (typeof payload !== "object" || payload === null) return null
+ const p = payload as Record
+ if (
+ typeof p.sessionID !== "string" ||
+ typeof p.goalId !== "string" ||
+ typeof p.planDocId !== "string" ||
+ typeof p.seq !== "number" ||
+ typeof p.expectedPlanVersion !== "number"
+ )
+ return null
+ return {
+ sessionID: p.sessionID,
+ goalId: p.goalId,
+ planDocId: p.planDocId,
+ seq: p.seq,
+ expectedPlanVersion: p.expectedPlanVersion,
+ }
+}
+
+// Build the next-tick command from a port result. Exposed so goal-manager's start() reuses the SAME key
+// scheme for the FIRST command (seq=0), guaranteeing no drift between the seed and the chain.
+export const tickCommand = (request: {
+ sessionID: string
+ goalId: string
+ planDocId: string
+ seq: number
+ expectedPlanVersion: number
+}): DeepAgentEvent.PublishInput => ({
+ type: LMNEvents.GOAL_TICK_REQUESTED,
+ source: "system",
+ workspaceID: request.sessionID,
+ actorID: request.sessionID,
+ // §N: the command is the goal's own work — normal priority; it is NOT an approval-queue candidate.
+ priority: "normal",
+ // Dedup identity — a redelivered command for the same (goal, seq) is a no-op at the bus.
+ idempotencyKey: `goal:tick:${request.goalId}:${request.seq}`,
+ payload: {
+ sessionID: request.sessionID,
+ goalId: request.goalId,
+ planDocId: request.planDocId,
+ seq: request.seq,
+ expectedPlanVersion: request.expectedPlanVersion,
+ },
+})
+
+// A paused command consumes the normal cursor key without advancing durable state. Resume therefore keeps
+// the current cursor in the payload (the tick's true pre-state) but uses a fresh seed identity; successors
+// return to the normal cursor namespace immediately after the resumed tick persists.
+export const resumeTickCommand = (request: Parameters[0]): DeepAgentEvent.PublishInput => ({
+ ...tickCommand(request),
+ idempotencyKey: `goal:tick:${request.goalId}:resume:${DeepAgentEvent.ID.create()}`,
+})
+
+export interface Interface {
+ /**
+ * Handle ONE goal.tick.requested delivery and discharge it. Flag off ⇒ ack (discharge) + drive nothing.
+ * Otherwise: run one tick via the port, then re-emit the next command (progress==="continue") or stop
+ * the chain (terminal/paused/stopped), and ack. A port DEFECT nacks (the bus retries the real tick).
+ * Exposed for deterministic testing; the background subscription calls it.
+ */
+ readonly handle: (event: DeepAgentEvent.Event) => Effect.Effect
+ /** §A3 retry pump for THIS group — re-drives pending deliveries whose backoff elapsed. */
+ readonly pumpRetries: (now?: number) => Effect.Effect
+}
+
+export class Service extends Context.Service()("@deepagent-code/GoalTickConsumer") {}
+
+export interface LayerOptions {
+ /** The tick execution port. Default = production makeGoalTickPort (wired via the full layer); tests inject a stub. */
+ readonly runTick: GoalTickPort
+ /** Start the background bus subscription + retry pump. Default true; tests set false + call handle(). */
+ readonly runLoop?: boolean
+ readonly retryPumpIntervalMs?: number
+}
+
+export const layerWith = (options: LayerOptions) =>
+ Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const bus = yield* DeepAgentEventBus.Service
+ const flags = yield* RuntimeFlags.Service
+ const runTick = options.runTick
+ const runLoop = options.runLoop ?? true
+ const retryPumpIntervalMs = options.retryPumpIntervalMs ?? DEFAULT_RETRY_PUMP_INTERVAL_MS
+
+ const ack = (event: DeepAgentEvent.Event) => bus.ack(TICK_GROUP, event.id)
+
+ const handle: Interface["handle"] = (event) =>
+ Effect.gen(function* () {
+ // Flag off ⇒ the event-driven path is dormant; the in-process driver is authoritative. Still ACK
+ // to discharge the durable delivery row (this group wildcard-less-subscribes only this type, but
+ // a stray delivery must not pile up).
+ if (!flags.v4MultiAgentRuntime) {
+ yield* ack(event)
+ return
+ }
+ const request = parseRequest(event.payload)
+ if (request == null) {
+ log.warn("goal.tick.requested with malformed payload; acking (dropped)", { eventID: event.id })
+ yield* ack(event)
+ return
+ }
+
+ // Execute exactly ONE tick. A defect degrades to a nack so the bus retries the REAL tick.
+ const outcome = yield* runTick(request).pipe(
+ Effect.map((r) => ({ ok: true as const, r })),
+ Effect.catchCause((cause) => Effect.succeed({ ok: false as const, cause })),
+ )
+
+ if (!outcome.ok) {
+ log.error("goal tick execution failed; nacking for retry", {
+ eventID: event.id,
+ goalId: request.goalId,
+ cause: Cause.pretty(outcome.cause),
+ })
+ yield* bus.nack({ subscriptionGroup: TICK_GROUP, eventID: event.id, reason: "goal tick execution failed" })
+ return
+ }
+
+ const { progress, nextSeq, nextExpectedPlanVersion } = outcome.r
+ if (progress === "continue") {
+ // Self-driving chain: publish the NEXT command. nextSeq advanced (progress → ledger.ticks++,
+ // no-progress replay → stallCount++), so its key differs and the bus publishes it — the chain
+ // never silently dies on a no-progress tick (the loop's stall guard still escalates).
+ yield* bus.publish(
+ tickCommand({
+ sessionID: request.sessionID,
+ goalId: request.goalId,
+ planDocId: request.planDocId,
+ seq: nextSeq,
+ expectedPlanVersion: nextExpectedPlanVersion,
+ }),
+ )
+ log.info("goal tick executed; re-emitted next command", {
+ goalId: request.goalId,
+ seq: request.seq,
+ nextSeq,
+ })
+ } else {
+ // terminal / paused / stopped: do NOT re-emit. The terminal FACT (goal.completed /
+ // needs_human / rolled_back) is emitted by the tick's own onStatus port; resume re-seeds the
+ // chain for a paused goal.
+ log.info("goal tick chain halted", { goalId: request.goalId, seq: request.seq, progress })
+ }
+ yield* ack(event)
+ })
+
+ const pumpRetries: Interface["pumpRetries"] = (now) =>
+ Effect.gen(function* () {
+ const due = yield* bus.dueRetries(now)
+ let redriven = 0
+ for (const delivery of due) {
+ if (delivery.subscriptionGroup !== TICK_GROUP) continue // only OUR group's deliveries.
+ const event = yield* bus.getByID(delivery.eventID)
+ if (!event) {
+ log.warn("retry: event missing for pending goal-tick delivery", { eventID: delivery.eventID })
+ continue
+ }
+ yield* handle(event) // re-runs the full ack/nack cycle (idempotent via the seq key).
+ redriven++
+ }
+ return redriven
+ })
+
+ if (runLoop) {
+ yield* bus
+ .subscribe({ type: LMNEvents.GOAL_TICK_REQUESTED, group: TICK_GROUP })
+ .pipe(
+ Stream.runForEach((event) =>
+ handle(event).pipe(
+ Effect.catchCause((cause) =>
+ Effect.sync(() => log.error("goal tick handle failed", { cause: Cause.pretty(cause) })),
+ ),
+ ),
+ ),
+ Effect.forkScoped,
+ )
+
+ yield* pumpRetries()
+ .pipe(
+ Effect.catchCause((cause) =>
+ Effect.sync(() => log.error("goal tick retry pump failed", { cause: Cause.pretty(cause) })).pipe(
+ Effect.as(0),
+ ),
+ ),
+ Effect.repeat(Schedule.spaced(Duration.millis(retryPumpIntervalMs))),
+ Effect.forkScoped,
+ )
+ }
+
+ return Service.of({ handle, pumpRetries })
+ }),
+ )
diff --git a/packages/deepagent-code/src/session/goal-tick-port.ts b/packages/deepagent-code/src/session/goal-tick-port.ts
new file mode 100644
index 00000000..e0c8edf1
--- /dev/null
+++ b/packages/deepagent-code/src/session/goal-tick-port.ts
@@ -0,0 +1,262 @@
+export * as GoalTickPort from "./goal-tick-port"
+
+import { Effect, Option } from "effect"
+import { DocumentStore } from "@deepagent-code/core/deepagent/document-store"
+import {
+ makeGoalLoop,
+ readGoalTickCursor,
+ readPendingPlanEdit,
+ persistPendingPlanEdit,
+} from "@deepagent-code/core/deepagent/goal-loop"
+import type { PlanInput } from "@deepagent-code/core/deepagent/plan-controller"
+import { DeepAgentEventBus } from "@deepagent-code/core/deepagent/deepagent-event-bus"
+import { ApprovalQueue } from "@deepagent-code/core/deepagent/approval-queue"
+import { WorkspaceV2 } from "@deepagent-code/core/workspace"
+import * as Log from "@deepagent-code/core/util/log"
+import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
+import type { InstanceStore } from "@/project/instance-store"
+import type { EventV2 } from "@deepagent-code/core/event"
+import type { Session } from "./session"
+import type { Agent } from "../agent/agent"
+import type { SessionPrompt } from "./prompt"
+import type { SessionRevert } from "./revert"
+import type { SessionSteer } from "./steer"
+import type { Provider } from "../provider/provider"
+import type { LSP } from "../lsp/lsp"
+import type { RuntimeFlags } from "../effect/runtime-flags"
+import { SessionID } from "./schema"
+import { GoalDriver, type GoalDriverPorts } from "./goal-driver"
+import {
+ GoalLoopWiring,
+ liveDiagnostics,
+ liveRollback,
+ makeTaskSubagentRunner,
+ type PanelQuestionInput,
+} from "./goal-loop-wiring"
+import { makeGoalStatusPublisher } from "./goal-status-publisher"
+import { RuntimeFlags as RuntimeFlagsService } from "../effect/runtime-flags"
+import { LSP as LSPService } from "../lsp/lsp"
+import { AgentGateway } from "@deepagent-code/core/agent-gateway"
+import type { GoalTickConsumer } from "./goal-tick-consumer"
+
+// V4.1 §N — the PRODUCTION `runTick` port for the GoalTickConsumer: execute EXACTLY ONE goal tick on a
+// COLD fiber, reconstructing the entire goal wiring from durable state + the event payload. This is the
+// piece that makes the event-driven chain survive a process restart: nothing about a running goal is held
+// in memory that this cannot rebuild from {sessionID, goalId} + the file-backed run_context doc.
+//
+// COLD-FIBER DISCIPLINE (mirrors makeEventTurnRunner / makeEventPanelPort): the GoalTickConsumer's
+// subscription runs on a background daemon fiber that carries NO ambient InstanceRef. EVERY
+// InstanceState-touching call (agents.get / sessions.get|create / sessionPrompt.* / provider.defaultModel /
+// SessionRevert / LSP) reads InstanceRef and `Effect.die`s without it. So we load the instance context for
+// the goal session's directory ONCE and wrap every such call in `withContext`. A die would pierce the
+// consumer's catchCause and nack forever; wrapping keeps the tick honest.
+//
+// PARENTING (§D invariant 不越权): the goal-worker turn is parented to the GOAL SESSION (parentSessionID =
+// sessionID), exactly as the in-process driver does — swapping to a fresh root would change permission
+// derivation. makeTaskSubagentRunner does NOT self-wrap withContext (it only ever ran on a request fiber),
+// so this port wraps it.
+
+const log = Log.create({ service: "goal-tick-port" })
+
+export type GoalTickPortDeps = {
+ readonly sessions: Session.Interface
+ readonly agents: Agent.Interface
+ readonly sessionPrompt: SessionPrompt.Interface
+ readonly revert: SessionRevert.Interface
+ readonly steerBuffer: SessionSteer.Interface
+ readonly provider: Provider.Interface
+ readonly lsp: LSP.Interface
+ readonly instanceStore: InstanceStore.Interface
+ readonly events: EventV2.Interface
+ readonly eventBus: DeepAgentEventBus.Interface
+ readonly approvalQueue: ApprovalQueue.Interface
+ readonly flags: RuntimeFlags.Info
+ /** The canonical store-root resolver (goal-manager.goalStoreRoot) — the SAME path the warm driver uses. */
+ readonly goalStoreRoot: (sessionID: string) => string
+}
+
+const defaultPanelQuestion = (): PanelQuestionInput => ({
+ question: "Is the current change safe and correct enough to complete this goal?",
+ codeRefs: [],
+ lenses: ["correctness", "security", "architecture"],
+})
+
+// A halt result that discharges the command WITHOUT re-emitting (progress="stopped") — used when the tick
+// genuinely cannot run (no model configured, wiring disabled). Distinct from a transient failure (which
+// the consumer nacks): a config problem won't fix on retry, so we ack + halt the chain rather than spin.
+const HALT: GoalTickConsumer.GoalTickPortResult = { progress: "stopped", nextSeq: 0, nextExpectedPlanVersion: 0 }
+
+// The cursor is persisted by the tick itself, before the consumer publishes its successor or acks the
+// current delivery. An advanced cursor therefore proves this command already committed. Returning the
+// current cursor lets a retry idempotently repair a missing successor; a command ahead of durable state is
+// invalid and must be nacked instead of executing out of order.
+export const recoverGoalTickRequest = (
+ request: GoalTickConsumer.GoalTickRequest,
+ cursor: ReturnType,
+) => {
+ if (cursor == null || cursor.seq === request.seq) return null
+ if (cursor.seq < request.seq) return "invalid" as const
+ return {
+ progress: cursor.phase === "running" ? "continue" : cursor.phase === "stopped" ? "stopped" : "terminal",
+ nextSeq: cursor.seq,
+ nextExpectedPlanVersion: cursor.planVersion,
+ } as const
+}
+
+/**
+ * Build the production GoalTickPort. One call = one cold-reconstructed tick.
+ */
+export const makeGoalTickPort =
+ (deps: GoalTickPortDeps): GoalTickConsumer.GoalTickPort =>
+ (request) =>
+ Effect.gen(function* () {
+ const sessionID = request.sessionID
+ const store = new DocumentStore(deps.goalStoreRoot(sessionID))
+ const recovered = recoverGoalTickRequest(request, readGoalTickCursor(store, sessionID, request.goalId))
+ if (recovered === "invalid") {
+ return yield* Effect.die(
+ new Error(`goal tick command ${request.seq} is ahead of durable state for ${request.goalId}`),
+ )
+ }
+ if (recovered != null) {
+ log.info("goal tick delivery already committed; repairing successor", {
+ goalId: request.goalId,
+ seq: request.seq,
+ nextSeq: recovered.nextSeq,
+ })
+ return recovered
+ }
+
+ // Reconstruct cwd + instance context from the goal session. sessions.get itself resolves through
+ // InstanceState, but it is called BEFORE we hold a ctx — so tolerate a die by loading the ctx from a
+ // best-effort directory. In practice the daemon runs in-process where session-state is on disk; the
+ // session row read here is via the DB service (Session.get), which does NOT need InstanceRef for the
+ // lookup itself in this codebase, but we still guard defensively.
+ const session = yield* deps.sessions.get(SessionID.make(sessionID)).pipe(Effect.orElseSucceed(() => undefined))
+ const directory = session?.directory ?? process.cwd()
+
+ // Establish the instance context on this cold fiber (see header). A load failure ⇒ we cannot run any
+ // InstanceState call → halt the chain (ack, no infinite retry).
+ const ctx = yield* deps.instanceStore.load({ directory }).pipe(Effect.orElseSucceed(() => undefined))
+ if (!ctx) {
+ log.warn("goal tick: could not load instance context; halting chain", { sessionID, goalId: request.goalId })
+ return HALT
+ }
+ const workspaceID =
+ session?.workspaceID && String(session.workspaceID).startsWith("wrk")
+ ? WorkspaceV2.ID.make(String(session.workspaceID))
+ : undefined
+ const withContext = (eff: Effect.Effect ) =>
+ eff.pipe(Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, workspaceID))
+
+ // Resolve the goal model (session model else provider default), wrapped so defaultModel doesn't die.
+ const model = yield* withContext(
+ Effect.gen(function* () {
+ if (session?.model) return { providerID: session.model.providerID, modelID: session.model.id }
+ const fallback = yield* deps.provider.defaultModel().pipe(Effect.option)
+ if (Option.isNone(fallback)) return null
+ return { providerID: fallback.value.providerID, modelID: fallback.value.modelID }
+ }),
+ ).pipe(Effect.orElseSucceed(() => null))
+ if (!model) {
+ log.warn("goal tick: no model configured; halting chain", { sessionID, goalId: request.goalId })
+ return HALT
+ }
+
+ // The turn runner — parented to the goal session, wrapped so its InstanceState calls don't die cold.
+ const baseRunner = makeTaskSubagentRunner({
+ sessions: deps.sessions,
+ agents: deps.agents,
+ sessionPrompt: deps.sessionPrompt,
+ parentSessionID: SessionID.make(sessionID),
+ model,
+ })
+ const runTurn: typeof baseRunner = (input) => withContext(baseRunner(input))
+
+ // Diagnostics + rollback, both wrapped (LSP / SessionRevert resolve through InstanceState).
+ const diagnostics = () =>
+ withContext(liveDiagnostics().pipe(Effect.provideService(LSPService.Service, deps.lsp)))
+ const rollback = liveRollback(deps.revert, (sid) =>
+ withContext(
+ deps.sessions
+ .messages({ sessionID: SessionID.make(sid) })
+ .pipe(
+ Effect.map((msgs) => msgs.at(-1)?.info.id ?? null),
+ Effect.catchCause(() => Effect.succeed(null)),
+ ),
+ ),
+ )
+ const wrappedRollback: typeof rollback = (rbInput) => withContext(rollback(rbInput))
+
+ // One goal-steer relay per tick, shared by the wiring (executor threads staged guidance) + the driver.
+ const steerRelay = GoalDriver.makeGoalSteerRelay()
+
+ const deps_ = yield* GoalLoopWiring.makeGoalLoopWiring({
+ store,
+ parentSessionID: sessionID,
+ cwd: directory,
+ runTurn,
+ panelQuestion: defaultPanelQuestion,
+ diagnostics,
+ rollback: wrappedRollback,
+ steerRelay,
+ }).pipe(Effect.provideService(RuntimeFlagsService.Service, deps.flags))
+ if (deps_ == null) {
+ log.warn("goal tick: goal loop disabled (experimentalGoalLoop off); halting chain", { sessionID })
+ return HALT
+ }
+
+ // The SHARED status publisher — IDENTICAL onStatus behaviour to the warm goal-manager path (mirror
+ // plan → session-state, publish goal.updated, flag-on mirror to bus + approval queue). No cacheStatus
+ // callback: the cold path has no in-memory control map (pause/resume/stop read the durable pointer).
+ const statusPublisher = makeGoalStatusPublisher({
+ events: deps.events,
+ sessions: deps.sessions,
+ eventBus: deps.eventBus,
+ approvalQueue: deps.approvalQueue,
+ v4MultiAgentRuntime: deps.flags.v4MultiAgentRuntime,
+ goalStoreRoot: deps.goalStoreRoot,
+ })
+
+ // Ports from DURABLE sources (no in-memory control map on the cold fiber):
+ // • shouldPause / shouldStop — the session-state active-goal pointer phase (pause/stop persist it).
+ // • goal-steer — the SessionSteer buffer on the goal session id + goal_steer delivery channel.
+ // • pendingPlanEdit — the durable pending-edit doc (persistPendingPlanEdit / readPendingPlanEdit).
+ const goalPhase = () => AgentGateway.DeepAgentSessionState.getActiveGoal(sessionID)?.phase
+ const ports: GoalDriverPorts = {
+ onStatus: (status) => statusPublisher.publishStatus(sessionID, status),
+ shouldPause: () => Effect.sync(() => goalPhase() === "paused"),
+ shouldStop: () => Effect.sync(() => goalPhase() === "stopped"),
+ pendingSteer: () =>
+ deps.steerBuffer.pending(SessionID.make(sessionID), GoalDriver.GOAL_STEER_DELIVERY).pipe(
+ Effect.map((rows) => rows.map((r) => ({ id: r.id, text: r.prompt.text }))),
+ Effect.catchCause(() => Effect.succeed([] as ReadonlyArray)),
+ ),
+ markSteerConsumed: (ids) =>
+ deps.steerBuffer
+ .markConsumed(SessionID.make(sessionID), [...ids], GoalDriver.GOAL_STEER_DELIVERY)
+ .pipe(Effect.catchCause(() => Effect.void)),
+ pendingPlanEdit: () =>
+ Effect.sync(() => readPendingPlanEdit(store, sessionID, request.goalId) as PlanInput | null),
+ markPlanEditConsumed: () =>
+ // Clear the durable slot (sentinel empty body) after the driver applied+re-baselined the edit.
+ Effect.sync(() => persistPendingPlanEdit(store, sessionID, request.goalId, null)),
+ }
+
+ const handle = { goalId: request.goalId, planDocId: request.planDocId, sessionId: sessionID }
+ const result = yield* GoalDriver.runOneTick(makeGoalLoop(deps_), { deps: deps_, handle, ports, steerRelay })
+
+ // The post-tick durable cursor is the next command identity and the retry checkpoint for this command.
+ // If state vanished, runOneTick cannot continue, so the fallback is unused by the consumer.
+ const cursor = readGoalTickCursor(store, sessionID, request.goalId)
+ const nextSeq = cursor?.seq ?? request.seq + 1
+ const nextExpectedPlanVersion = cursor?.planVersion ?? request.expectedPlanVersion
+
+ return { progress: result.progress, nextSeq, nextExpectedPlanVersion }
+ }).pipe(
+ // The port lives on `never` — a defect here (unexpected) must NOT crash the consumer's stream. But we
+ // WANT a genuine transient failure to nack for retry, so we RE-RAISE as a die: the consumer's
+ // catchCause converts it to a nack (retry the REAL tick). We only swallow to HALT for the explicit
+ // config cases above. So: no catch here — let a defect propagate to the consumer's nack path.
+ Effect.orDie,
+ )
diff --git a/packages/deepagent-code/src/session/overflow.ts b/packages/deepagent-code/src/session/overflow.ts
index bd371ced..ed981c3d 100644
--- a/packages/deepagent-code/src/session/overflow.ts
+++ b/packages/deepagent-code/src/session/overflow.ts
@@ -4,9 +4,82 @@ import { SessionV1 } from "@deepagent-code/core/v1/session"
import type { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform"
import type { MessageV2 } from "./message-v2"
+import { Schema } from "effect"
const COMPACTION_BUFFER = 20_000
+// V4.0.1 P0 — three-layer SOFT-LANDING compaction thresholds.
+//
+// The single hard `usable()` line is split into three defensive lines so the model gets a warning
+// (reminder) and then a one-shot "临终笔记" fallback to flush un-persisted state into the durable plan
+// doc BEFORE a lossy LLM-summary compaction (hard).
+//
+// softLine = usable() × REMINDER_FRACTION (nudge: write decisions/findings to the plan)
+// fallbackLine = usable() − AUTO_COMPACT_FALLBACK_BUFFER (last chance: flush now, keep all tools)
+// hardLine = usable() (existing behavior: real LLM-summary compaction)
+//
+// Both are env-overridable so operators can retune per model window without a rebuild (see §2.5). The
+// exported consts are the defaults; `reminderFraction()`/`fallbackBuffer()` read the env at call time.
+export const REMINDER_FRACTION = 0.8
+export const AUTO_COMPACT_FALLBACK_BUFFER = 12_000 // 硬线内侧预留,够模型写一轮落盘笔记
+
+function reminderFraction(): number {
+ const raw = Number(process.env["DEEPAGENT_CODE_REMINDER_FRACTION"])
+ return Number.isFinite(raw) && raw > 0 && raw < 1 ? raw : REMINDER_FRACTION
+}
+
+function fallbackBuffer(): number {
+ const raw = Number(process.env["DEEPAGENT_CODE_AUTO_COMPACT_FALLBACK_BUFFER"])
+ return Number.isFinite(raw) && raw >= 0 ? raw : AUTO_COMPACT_FALLBACK_BUFFER
+}
+
+export type CompactionPhase = "ok" | "reminder" | "fallback" | "hard"
+
+export interface OverflowStatus {
+ readonly phase: CompactionPhase
+ readonly used: number // body-after-prefix token 估算
+ readonly softLine: number // usable × REMINDER_FRACTION
+ readonly fallbackLine: number // hardLine - AUTO_COMPACT_FALLBACK_BUFFER
+ readonly hardLine: number // 现有 usable()
+}
+
+// V4.0.1 P0 — the durable soft-landing state, carried on session metadata so it survives cold recovery.
+// One "generation" of the three-layer defense lives per windowEpoch; a hard compaction bumps the epoch
+// and resets the reminder/fallback flags so the next generation can warn + flush again.
+export const CompactionSoftLandingState = Schema.Struct({
+ windowEpoch: Schema.Int, // 每次硬压缩 +1,用于世代隔离
+ reminderDeliveredAtTurn: Schema.optional(Schema.Int),
+ autoCompactFallbackDelivered: Schema.Boolean, // 本 epoch 是否已注入 fallback
+ // V4.0.1 P0 §2.3 BodyAfterPrefix — the per-window input-token BASELINE (Codex's `prefill_input_tokens`,
+ // core/src/state/auto_compact_window.rs). Captured (latched) from the provider-reported input side of
+ // the FIRST response after a window opens; `overflowStatus` subtracts it so the soft/fallback/hard lines
+ // fire on BODY growth, not on the fixed static prefix. Cleared (undefined) when windowEpoch bumps — the
+ // next generation re-latches. server-observed only: we take the real billed input, never a tokenizer.
+ prefillInputTokens: Schema.optional(Schema.Int),
+ // V4.0.1 P0b OUTPUT soft-landing — how many times we have auto-continued the CURRENT run of length-capped
+ // responses. Reset to 0 on any non-"length" finish (a natural stop breaks the run). A hard cap on this
+ // (OUTPUT_CONTINUATION_MAX) prevents an infinite continue loop — the knob Codex lacks (it only has the
+ // transport-retry cap, the wrong lever for output truncation).
+ outputContinuationCount: Schema.optional(Schema.Int),
+}).annotate({ identifier: "CompactionSoftLandingState" })
+export type CompactionSoftLandingState = Schema.Schema.Type
+
+export const initialSoftLandingState: CompactionSoftLandingState = {
+ windowEpoch: 0,
+ autoCompactFallbackDelivered: false,
+}
+
+// V4.0.1 P0b — the hard ceiling on consecutive output-length auto-continuations, independent of the
+// transport-retry cap. After this many "continue from where you were cut off" injections in a row without
+// a natural stop, we give up and end the turn (avoids a model that keeps hitting the output cap forever).
+// Env-overridable for tuning per model.
+export const OUTPUT_CONTINUATION_MAX = 3
+
+export function outputContinuationMax(): number {
+ const raw = Number(process.env["DEEPAGENT_CODE_OUTPUT_CONTINUATION_MAX"])
+ return Number.isInteger(raw) && raw >= 0 ? raw : OUTPUT_CONTINUATION_MAX
+}
+
export function usable(input: { cfg: ConfigV1.Info; model: Provider.Model; outputTokenMax?: number }) {
const context = input.model.limit.context
if (context === 0) return 0
@@ -19,16 +92,130 @@ export function usable(input: { cfg: ConfigV1.Info; model: Provider.Model; outpu
: Math.max(0, context - ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax))
}
+// Collapse an assistant token record to a single "used" count, matching the historical isOverflow math
+// (prefer the provider-reported total, else sum the components).
+export function tokensUsed(tokens: SessionV1.Assistant["tokens"]): number {
+ return tokens.total || tokens.input + tokens.output + tokens.cache.read + tokens.cache.write
+}
+
+// V4.0.1 P0 — three-value overflow layering. `softLanding` (default true) gates the reminder/fallback
+// layers: with it false this collapses to the pre-V4.0.1 single-threshold ok/hard behavior (逐字节
+// equivalent), so callers can wire it straight to the softLandingCompaction flag.
+//
+// BodyAfterPrefix (§2.3): `prefixTokens` is subtracted from the raw count so a large byte-stable static
+// prefix (system prompt + skills + tool defs) does not eat the soft-landing budget. Per the §9.1 risk
+// note, callers that cannot cheaply obtain an accurate prefix estimate pass 0 (equivalent to whole-body
+// accounting) — the deduction is wired but a no-op until a real estimate is available.
+export function overflowStatus(input: {
+ cfg: ConfigV1.Info
+ model: Provider.Model
+ outputTokenMax?: number
+ tokens: number
+ prefixTokens?: number
+ softLanding?: boolean
+}): OverflowStatus {
+ const hardLine = usable(input)
+ const softLandingEnabled = input.softLanding ?? true
+ const prefix = Math.max(0, input.prefixTokens ?? 0)
+ // Body-after-prefix (§2.3, Codex core/src/session/context_window.rs): subtract the per-window prefix
+ // BASELINE so the soft/fallback/hard lines fire on BODY growth, not on the fixed static prefix. Never
+ // let the baseline drive `used` negative.
+ const used = Math.max(0, input.tokens - prefix)
+
+ // Reminder line first, then clamp the fallback line into [softLine, hardLine] so the three lines stay
+ // monotonic even on small windows where hardLine - buffer would otherwise fall below the soft line.
+ const softLine = hardLine * reminderFraction()
+ const fallbackLine = Math.min(hardLine, Math.max(softLine, hardLine - fallbackBuffer()))
+
+ // No compaction ⇒ no soft-landing (autocompact disabled, or the model reports no context window).
+ if (input.cfg.compaction?.auto === false || input.model.limit.context === 0) {
+ return { phase: "ok", used, softLine, fallbackLine, hardLine }
+ }
+
+ // Full-window SAFETY CAP (Codex's second, independent check — core/src/session/context_window.rs: body
+ // >= 0.9*window OR total >= full window). body-after-prefix is the PRIMARY trigger, but a huge prefix
+ // must never let the RAW un-deducted total silently blow past the real input window. So a hard
+ // compaction ALSO fires when the raw total reaches the model's actual input limit (the true window,
+ // ABOVE the reserved-output `hardLine`), whichever crosses first. When we have no input limit, fall
+ // back to context. Only meaningful once a prefix is deducted (prefix>0); with prefix=0, used==raw so
+ // this is redundant and byte-for-byte the pre-BodyAfterPrefix behavior.
+ const fullWindow = input.model.limit.input || input.model.limit.context
+ const rawOverFullWindow = prefix > 0 && input.tokens >= fullWindow
+
+ const phase: CompactionPhase =
+ used >= hardLine || rawOverFullWindow
+ ? "hard"
+ : !softLandingEnabled
+ ? "ok"
+ : used >= fallbackLine
+ ? "fallback"
+ : used >= softLine
+ ? "reminder"
+ : "ok"
+
+ return { phase, used, softLine, fallbackLine, hardLine }
+}
+
+// How many turns must pass before the soft REMINDER is re-injected while the used tokens linger in the
+// [soft, fallback) band — a debounce so a long stretch near the soft line does not spam the tail.
+export const REMINDER_DEBOUNCE_TURNS = 5
+
+export type SoftLandingAction = "none" | "reminder" | "fallback" | "hard"
+
+// V4.0.1 P0 — the PURE soft-landing state machine. Given the current overflow `status`, the persisted
+// `state`, and the current turn `step`, decide what side effect the turn loop should run and the next
+// durable state. Keeping this pure (no Effect, no IO) makes the four-band + generation-reset + fallback
+// idempotency directly unit-testable; prompt.ts only executes the returned action.
+export function softLandingDecision(input: {
+ status: OverflowStatus
+ state: CompactionSoftLandingState
+ step: number
+ debounceTurns?: number
+}): { action: SoftLandingAction; nextState: CompactionSoftLandingState } {
+ const { status, state, step } = input
+ const debounce = input.debounceTurns ?? REMINDER_DEBOUNCE_TURNS
+
+ switch (status.phase) {
+ case "hard":
+ // Real LLM-summary compaction happens. Bump the generation and clear the soft-landing flags so the
+ // NEXT window can warn + flush again from scratch. Note the fresh object also DROPS
+ // prefillInputTokens (§2.3 clear_prefill: the new window re-latches its own baseline from the first
+ // post-compaction response) and outputContinuationCount (a fresh window resets the output run).
+ return {
+ action: "hard",
+ nextState: { windowEpoch: state.windowEpoch + 1, autoCompactFallbackDelivered: false },
+ }
+ case "fallback":
+ // One forced "临终笔记" per generation. Already delivered ⇒ no-op (idempotent within the epoch).
+ if (state.autoCompactFallbackDelivered) return { action: "none", nextState: state }
+ return {
+ action: "fallback",
+ nextState: { ...state, autoCompactFallbackDelivered: true, reminderDeliveredAtTurn: step },
+ }
+ case "reminder": {
+ const last = state.reminderDeliveredAtTurn
+ if (last !== undefined && step - last < debounce) return { action: "none", nextState: state }
+ return { action: "reminder", nextState: { ...state, reminderDeliveredAtTurn: step } }
+ }
+ default:
+ return { action: "none", nextState: state }
+ }
+}
+
export function isOverflow(input: {
cfg: ConfigV1.Info
tokens: SessionV1.Assistant["tokens"]
model: Provider.Model
outputTokenMax?: number
}) {
- if (input.cfg.compaction?.auto === false) return false
- if (input.model.limit.context === 0) return false
-
- const count =
- input.tokens.total || input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write
- return count >= usable(input)
+ // Thin backward-compatible wrapper: overflow == the hard line is crossed. The softLanding layers never
+ // change where the hard line sits, so every existing caller keeps its exact semantics.
+ return (
+ overflowStatus({
+ cfg: input.cfg,
+ model: input.model,
+ outputTokenMax: input.outputTokenMax,
+ tokens: tokensUsed(input.tokens),
+ }).phase === "hard"
+ )
}
diff --git a/packages/deepagent-code/src/session/prompt.ts b/packages/deepagent-code/src/session/prompt.ts
index e5566e05..b5ae47a9 100644
--- a/packages/deepagent-code/src/session/prompt.ts
+++ b/packages/deepagent-code/src/session/prompt.ts
@@ -18,6 +18,14 @@ import { configureGateway } from "@/deepagent/config"
import { type Tool as AITool, tool, jsonSchema, streamText, type ModelMessage } from "ai"
import type { JSONSchema7 } from "@ai-sdk/provider"
import { SessionCompaction } from "./compaction"
+import {
+ overflowStatus,
+ tokensUsed,
+ softLandingDecision,
+ outputContinuationMax,
+ initialSoftLandingState,
+ CompactionSoftLandingState,
+} from "./overflow"
import { SystemPrompt } from "./system"
import { Instruction } from "./instruction"
import { Plugin } from "../plugin"
@@ -84,6 +92,7 @@ import { SessionReminders } from "./reminders"
import { SessionTools } from "./tools"
import { LLMEvent } from "@deepagent-code/llm"
import { ConversationLogWriter } from "./conversation-log-writer"
+import { collectVolatileFacts, refreshWorldState } from "./context-ledger"
import { CodeIndexTrigger } from "./code-index-trigger"
// @ts-ignore
@@ -1946,6 +1955,125 @@ export const layer = Layer.effect(
return pending.length
})
+ // ── V4.0.1 P0: three-layer SOFT-LANDING compaction ─────────────────────────────────────────────
+ // The durable soft-landing state lives on session metadata (survives cold recovery, same store as
+ // every other durable session field). This key namespaces it so it never collides with other
+ // metadata producers.
+ const SOFT_LANDING_METADATA_KEY = "compactionSoftLanding"
+ const decodeSoftLanding = Schema.decodeUnknownOption(CompactionSoftLandingState)
+
+ const readSoftLandingState: (sessionID: SessionID) => Effect.Effect = Effect.fn(
+ "SessionPrompt.readSoftLandingState",
+ )(function* (sessionID: SessionID) {
+ const session = yield* sessions.get(sessionID).pipe(Effect.orElseSucceed(() => undefined))
+ const raw = session?.metadata?.[SOFT_LANDING_METADATA_KEY]
+ return Option.getOrElse(decodeSoftLanding(raw), () => initialSoftLandingState)
+ })
+
+ const writeSoftLandingState: (
+ sessionID: SessionID,
+ state: CompactionSoftLandingState,
+ ) => Effect.Effect = Effect.fn("SessionPrompt.writeSoftLandingState")(function* (sessionID, state) {
+ const session = yield* sessions.get(sessionID).pipe(Effect.orElseSucceed(() => undefined))
+ // Merge into existing metadata so we never clobber a co-tenant key.
+ const metadata = { ...(session?.metadata ?? {}), [SOFT_LANDING_METADATA_KEY]: state }
+ yield* sessions.setMetadata({ sessionID, metadata }).pipe(Effect.ignore)
+ })
+
+ // reminder (soft line): a lightweight, non-compacting tail nudge asking the model to persist key
+ // decisions/findings into the plan's evidence/worklog. Reuses the SAME tail-user-message channel as
+ // steering (never mutates the static system prefix → prompt cache stays intact). `synthetic` marks
+ // it internal so it doesn't leak into previews/archives; it still reaches the model as a user text.
+ const REMINDER_TAIL_TEXT = [
+ "",
+ "上下文接近上限。请把关键决策 / 发现 / 下一步意图写进 plan 的 evidence 或 worklog,避免压缩时丢失。",
+ "文件与环境的当前值无需复述,系统会自动重注入。",
+ " ",
+ ].join("\n")
+
+ const injectTailReminder: (
+ sessionID: SessionID,
+ text: string,
+ model: { providerID: ProviderV2.ID; modelID: ModelV2.ID },
+ agentName: string,
+ ) => Effect.Effect = Effect.fn("SessionPrompt.injectTailReminder")(function* (
+ sessionID,
+ text,
+ model,
+ agentName,
+ ) {
+ const msg = yield* sessions.updateMessage({
+ id: MessageID.ascending(),
+ role: "user",
+ sessionID,
+ agent: agentName,
+ model,
+ time: { created: Date.now() },
+ })
+ yield* sessions.updatePart({
+ id: PartID.ascending(),
+ messageID: msg.id,
+ sessionID,
+ type: "text",
+ synthetic: true,
+ text,
+ })
+ })
+
+ // fallback ("临终笔记" line): the last chance before a hard compaction. All tools stay available so
+ // the model can call the plan-edit tool to固化 un-persisted state. Under a goal (loop/design) mode we
+ // additionally name the plan tool. §2.4 template — short, natural language.
+ const fallbackTailText = (sessionID: SessionID) => {
+ const goalActive = AgentGateway.DeepAgentSessionState.getActiveGoal(sessionID) != null
+ return [
+ "",
+ "上下文即将压缩。这是压缩前最后一次机会。",
+ "请立刻把以下内容写入持久状态,不要开始新的探索:",
+ "- 尚未记录的关键决策与理由",
+ "- 已经得到但未落盘的中间结论 / 数据引用",
+ "- 明确的下一步意图(写进 plan 的 next / worklog)",
+ goalActive ? "用 `plan` 工具更新 goal+plan,把上述内容落进 evidence/worklog。" : "",
+ "完成落盘后停止本轮。文件与环境的当前值无需复述,系统会自动重注入。",
+ " ",
+ ]
+ .filter(Boolean)
+ .join("\n")
+ }
+
+ // V4.0.1 P0b OUTPUT soft-landing — the "continue from the cutoff" nudge injected when a response was
+ // truncated at the output-token ceiling (finish === "length") with no pending tool call. Unlike Codex
+ // (which re-sends the identical request and re-hits the same cap), we append the model's already-
+ // streamed partial text as history and ask it to RESUME — so the pieces stitch by continuation.
+ const OUTPUT_CONTINUE_TAIL_TEXT = [
+ "",
+ "你上一轮的输出因达到输出长度上限被截断(未自然结束)。请直接从被截断处继续,",
+ "不要重复已经输出的内容,也不要重新开头。若已实质完成,简短收尾即可。",
+ " ",
+ ].join("\n")
+
+ // V4.0.1 P1 (§3.3) — post-hard-compaction World State re-injection. After a hard compaction the
+ // (now-narrowed) summary deliberately dropped file/env/diagnostics; this re-injects their LATEST
+ // values as a TAIL user block (reuses the SAME injectTailReminder primitive — never the static system
+ // prefix, so prompt cache is preserved) so the model sees current truth, not a stale summary value.
+ // Gated by worldStateReinjection (the same flag that narrowed the summary — no information hole).
+ // Bounded IO: git + env only, collected once per compaction. Default-safe: any defect ⇒ no-op.
+ const injectWorldStateTail: (
+ sessionID: SessionID,
+ workspacePath: string | undefined,
+ model: { providerID: ProviderV2.ID; modelID: ModelV2.ID },
+ agentName: string,
+ ) => Effect.Effect = Effect.fn("SessionPrompt.injectWorldStateTail")(function* (
+ sessionID,
+ workspacePath,
+ model,
+ agentName,
+ ) {
+ if (!workspacePath) return
+ const facts = yield* collectVolatileFacts(workspacePath)
+ const rendered = yield* refreshWorldState({ workspacePath, facts })
+ if (rendered.trim().length > 0) yield* injectTailReminder(sessionID, rendered, model, agentName)
+ })
+
const runLoop: (sessionID: SessionID, drainFirst?: boolean) => Effect.Effect = Effect.fn(
"SessionPrompt.run",
)(
@@ -2025,6 +2153,27 @@ export const layer = Layer.effect(
!hasToolCalls &&
lastUser.id < lastAssistant.id
) {
+ // V4.0.1 P0b OUTPUT soft-landing — a response cut off at the output-token ceiling finishes with
+ // "length" (never "tool-calls", so it reaches here). Instead of ending the turn mid-sentence,
+ // inject a bounded "continue from the cutoff" nudge and loop once more so the model resumes; the
+ // already-streamed partial is in history, so the continuation stitches on. Bounded by
+ // OUTPUT_CONTINUATION_MAX consecutive continuations (a fresh count each turn once a natural stop
+ // resets it) to prevent an infinite loop — the knob Codex lacks. Context growth from the extra
+ // turn is caught by the top-of-loop overflow check on the next pass (compaction stays separate).
+ if (flags.outputSoftLanding && lastAssistant.finish === "length") {
+ const sls = yield* readSoftLandingState(sessionID)
+ const done = sls.outputContinuationCount ?? 0
+ if (done < outputContinuationMax()) {
+ yield* writeSoftLandingState(sessionID, { ...sls, outputContinuationCount: done + 1 })
+ yield* injectTailReminder(sessionID, OUTPUT_CONTINUE_TAIL_TEXT, lastUser.model, lastUser.agent)
+ yield* slog.info("output soft-landing: continuing after length cutoff", {
+ continuation: done + 1,
+ max: outputContinuationMax(),
+ })
+ continue
+ }
+ yield* slog.warn("output soft-landing: continuation cap reached, ending turn", { max: outputContinuationMax() })
+ }
const orphan = lastAssistantMsg?.parts.find(
(part): part is SessionV1.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part),
)
@@ -2038,6 +2187,13 @@ export const layer = Layer.effect(
yield* slog.info("exiting loop")
break
}
+ // Output soft-landing: a natural stop (or any non-length finish that keeps looping via tool
+ // calls) resets the consecutive-continuation run so a later length cutoff gets the full budget.
+ if (flags.outputSoftLanding && lastAssistant?.finish && lastAssistant.finish !== "length") {
+ const sls = yield* readSoftLandingState(sessionID)
+ if ((sls.outputContinuationCount ?? 0) !== 0)
+ yield* writeSoftLandingState(sessionID, { ...sls, outputContinuationCount: 0 })
+ }
step++
if (step === 1) {
@@ -2070,13 +2226,73 @@ export const layer = Layer.effect(
continue
}
- if (
- lastFinished &&
- lastFinished.summary !== true &&
- (yield* compaction.isOverflow({ tokens: lastFinished.tokens, model }))
- ) {
- yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true })
- continue
+ // V4.0.1 P0 — turn-start soft-landing / overflow check. With softLandingCompaction OFF this is
+ // byte-for-byte the pre-V4.0.1 single-threshold path (isOverflow → compaction.create). With it
+ // ON, overflowStatus layers ok → reminder → fallback → hard: warn (tail nudge), then one forced
+ // "临终笔记" fallback (all tools retained), then the SAME hard compaction. `phase === "hard"` is
+ // exactly `isOverflow`, and the reminder/fallback layers never move the hard line, so the
+ // compaction trigger is unchanged.
+ if (lastFinished && lastFinished.summary !== true) {
+ if (!flags.softLandingCompaction) {
+ if (yield* compaction.isOverflow({ tokens: lastFinished.tokens, model })) {
+ yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true })
+ continue
+ }
+ } else {
+ const cfg = yield* config.get()
+ const slState0 = yield* readSoftLandingState(sessionID)
+ // BodyAfterPrefix (§2.3, Codex core/src/state/auto_compact_window.rs): latch a per-window
+ // input-token BASELINE from the provider-reported input side of the FIRST response of this
+ // window (input + cached read/write = the full billed input, matching the goal ledger's
+ // carriedPrefix). Once set it is pinned for the window (cleared on the epoch bump at a hard
+ // compaction) — server-observed only, no tokenizer. `overflowStatus` subtracts it so the
+ // lines fire on body growth; a full-window safety cap still guards the raw total.
+ const billedInput =
+ lastFinished.tokens.input + lastFinished.tokens.cache.read + lastFinished.tokens.cache.write
+ const slState =
+ slState0.prefillInputTokens === undefined && billedInput > 0
+ ? { ...slState0, prefillInputTokens: billedInput }
+ : slState0
+ if (slState !== slState0) yield* writeSoftLandingState(sessionID, slState)
+ const status = overflowStatus({
+ cfg,
+ model,
+ outputTokenMax: flags.outputTokenMax,
+ tokens: tokensUsed(lastFinished.tokens),
+ prefixTokens: slState.prefillInputTokens ?? 0,
+ softLanding: true,
+ })
+ const { action, nextState } = softLandingDecision({ status, state: slState, step })
+ if (action === "reminder") {
+ yield* writeSoftLandingState(sessionID, nextState)
+ yield* injectTailReminder(sessionID, REMINDER_TAIL_TEXT, lastUser.model, lastUser.agent)
+ yield* slog.info("soft-landing reminder injected", { used: status.used, softLine: status.softLine })
+ continue
+ }
+ if (action === "fallback") {
+ yield* writeSoftLandingState(sessionID, nextState)
+ yield* injectTailReminder(sessionID, fallbackTailText(sessionID), lastUser.model, lastUser.agent)
+ yield* slog.info("soft-landing fallback injected", {
+ used: status.used,
+ fallbackLine: status.fallbackLine,
+ })
+ continue
+ }
+ if (action === "hard") {
+ // Bump the generation + reset flags BEFORE compaction so a mid-compaction crash still
+ // recovers into the fresh window (the durable write is the世代 marker).
+ yield* writeSoftLandingState(sessionID, nextState)
+ yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true })
+ // P1 §3.3: re-inject the latest World State as a TAIL block right after the hard compaction
+ // so the model sees current file/env values, not the summary's (now-narrowed) stale ones.
+ // Ordered after compaction.create ⇒ higher message id ⇒ sits at the tail after the summary.
+ if (flags.worldStateReinjection)
+ yield* injectWorldStateTail(sessionID, ctx.directory, lastUser.model, lastUser.agent)
+ continue
+ }
+ // action === "none": fallback already delivered this epoch (still under hard line), reminder
+ // debounced, or below the soft line — proceed with the turn normally.
+ }
}
const agent = yield* agents.get(lastUser.agent)
@@ -2227,6 +2443,16 @@ export const layer = Layer.effect(
if (result === "stop") return "break" as const
if (result === "compact") {
+ // V4.0.1 P0 — a turn-internal hard compaction (the provider signalled overflow mid-stream).
+ // This IS a hard rollover, so bump the soft-landing generation + reset its flags so the next
+ // window can warn + flush again. Gated by softLandingCompaction; OFF ⇒ unchanged behavior.
+ if (flags.softLandingCompaction) {
+ const slState = yield* readSoftLandingState(sessionID)
+ yield* writeSoftLandingState(sessionID, {
+ windowEpoch: slState.windowEpoch + 1,
+ autoCompactFallbackDelivered: false,
+ })
+ }
yield* compaction.create({
sessionID,
agent: lastUser.agent,
@@ -2234,6 +2460,10 @@ export const layer = Layer.effect(
auto: true,
overflow: !handle.message.finish,
})
+ // P1 §3.3: re-inject the latest World State as a TAIL block right after this hard rollover
+ // (same responsibility-separation intent as the turn-start branch). Gated by the same flag.
+ if (flags.worldStateReinjection)
+ yield* injectWorldStateTail(sessionID, ctx.directory, lastUser.model, lastUser.agent)
}
return "continue" as const
}).pipe(
diff --git a/packages/deepagent-code/src/session/v4-event-runtime.ts b/packages/deepagent-code/src/session/v4-event-runtime.ts
index 1be820d0..1415ee1d 100644
--- a/packages/deepagent-code/src/session/v4-event-runtime.ts
+++ b/packages/deepagent-code/src/session/v4-event-runtime.ts
@@ -15,6 +15,7 @@ import { Session } from "./session"
import { SessionPrompt } from "./prompt"
import { Agent } from "../agent/agent"
import { Provider } from "../provider/provider"
+import { LSP } from "../lsp/lsp"
import { InstanceStore } from "@/project/instance-store"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { WorkspaceV2 } from "@deepagent-code/core/workspace"
@@ -33,6 +34,13 @@ import { makeTaskSubagentRunner } from "./goal-loop-wiring"
import { AgentPush } from "./agent-push"
import { DigestBuilder } from "./digest-builder"
import { SupervisorNotifier } from "./supervisor-notifier"
+// V4.1 §N — the event-driven goal-tick consumer + its production cold-reconstruction port.
+import { GoalTickConsumer } from "./goal-tick-consumer"
+import { GoalTickPort } from "./goal-tick-port"
+import { goalStoreRoot } from "./goal-manager"
+import { SessionRevert } from "./revert"
+import { SessionSteer } from "./steer"
+import { EventV2Bridge } from "@/event-v2-bridge"
// §C3 (P2.9) — file locks + code-graph symbols.
import { FileLock } from "@deepagent-code/core/file-lock"
import { openProjectStore } from "@deepagent-code/core/deepagent/durable-knowledge-store"
@@ -684,6 +692,51 @@ const panelConsumerLayer = Layer.unwrap(
}),
)
+// V4.1 §N — the GOAL TICK CONSUMER. Subscribes goal.tick.requested and executes each tick via the
+// production cold-reconstruction port (makeGoalTickPort), then re-emits the next command. This is what
+// makes the goal-loop tick GENUINELY event-driven with cross-process cold recovery: a goal survives a
+// process restart because every tick rebuilds its wiring from the durable run_context doc + the event
+// payload (no in-memory control map needed). Draws the SAME session stack makeEventTurnRunner uses, plus
+// SessionRevert / SessionSteer / LSP / EventV2Bridge for the rollback / goal-steer / diagnostics / SSE
+// ports, all from the shared graph.
+//
+// FLAG COUPLING: runLoop = v4MultiAgentRuntime (the master event-driven switch — the goal-manager's
+// dual-path start publishes the FIRST command only on this flag). Default posture matches the flag: with
+// it off, runLoop is false ⇒ NO subscription ⇒ the "goal-tick-consumer" group is never registered ⇒ no
+// pending-row pileup, and handle() additionally acks-and-drives-nothing on a stray delivery.
+const goalTickConsumerLayer = Layer.unwrap(
+ Effect.gen(function* () {
+ const flags = yield* RuntimeFlags.Service
+ const sessions = yield* Session.Service
+ const agents = yield* Agent.Service
+ const sessionPrompt = yield* SessionPrompt.Service
+ const revert = yield* SessionRevert.Service
+ const steerBuffer = yield* SessionSteer.Service
+ const provider = yield* Provider.Service
+ const lsp = yield* LSP.Service
+ const instanceStore = yield* InstanceStore.Service
+ const events = yield* EventV2Bridge.Service
+ const eventBus = yield* DeepAgentEventBus.Service
+ const approvalQueue = yield* ApprovalQueue.Service
+ const runTick = GoalTickPort.makeGoalTickPort({
+ sessions,
+ agents,
+ sessionPrompt,
+ revert,
+ steerBuffer,
+ provider,
+ lsp,
+ instanceStore,
+ events,
+ eventBus,
+ approvalQueue,
+ flags,
+ goalStoreRoot,
+ })
+ return GoalTickConsumer.layerWith({ runTick, runLoop: flags.v4MultiAgentRuntime })
+ }),
+)
+
/**
* The full V4 event-runtime, ready to merge into the instance app graph. Starts (as scoped daemons):
* the EventDispatcher (router + scheduler tick + retry pump), the MultiAgentRuntime (DispatchPort),
@@ -717,6 +770,9 @@ export const layer = Layer.mergeAll(
// All flag-gated on v4AgentPushEnabled; inert (no push, no flush) when off. Draws DeepAgentEventBus /
// Database / WorkspaceConfig / IMRepository / RuntimeFlags from the shared graph.
pushStackLayer,
+ // V4.1 §N — the event-driven goal-tick consumer. Gated on v4MultiAgentRuntime (see goalTickConsumerLayer).
+ // Shares the ONE DeepAgentEventBus + ApprovalQueue + session stack with the rest of the runtime.
+ goalTickConsumerLayer,
).pipe(
Layer.provideMerge(runtimeLayer),
)
diff --git a/packages/deepagent-code/src/settings/store.ts b/packages/deepagent-code/src/settings/store.ts
index f972c121..f08b16ca 100644
--- a/packages/deepagent-code/src/settings/store.ts
+++ b/packages/deepagent-code/src/settings/store.ts
@@ -5,7 +5,7 @@ import { OFFICIAL_PROVIDER_ID_SET } from "@deepagent-code/core/provider-official
/**
* First-party settings store — the single home for settings that must NOT live in the
- * user-editable config file (`deepagent-code.jsonc`, which is reserved for third-party
+ * user-editable config file (`config.jsonc`, which is reserved for third-party
* providers). Two families live here:
*
* 1. `deepagent` — first-party runtime settings (prompt/intelligence/agent-mode/self-learning +
diff --git a/packages/deepagent-code/src/skill/index.ts b/packages/deepagent-code/src/skill/index.ts
index 2582e810..5d7a0cab 100644
--- a/packages/deepagent-code/src/skill/index.ts
+++ b/packages/deepagent-code/src/skill/index.ts
@@ -32,7 +32,7 @@ const SKILL_PATTERN = "**/SKILL.md"
// actual schemas instead of guesses.
const CUSTOMIZE_DEEPAGENT_CODE_SKILL_NAME = "customize-deepagent-code"
const CUSTOMIZE_DEEPAGENT_CODE_SKILL_DESCRIPTION =
- "Use ONLY when the user is editing or creating deepagent-code's own configuration: deepagent-code.json, deepagent-code.jsonc, files under .deepagent-code/, or files under ~/.config/deepagent-code/. Also use when creating or fixing deepagent-code agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring deepagent-code itself."
+ "Use ONLY when the user is editing or creating deepagent-code's own configuration: config.jsonc, config.json, files under .deepagent-code/, or files under ~/.deepagent/code/. Also use when creating or fixing deepagent-code agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring deepagent-code itself."
const CUSTOMIZE_DEEPAGENT_CODE_SKILL_BODY = SkillPlugin.CustomizeOpencodeContent
export const Info = Schema.Struct({
diff --git a/packages/deepagent-code/test/config/config.test.ts b/packages/deepagent-code/test/config/config.test.ts
index 18cfe95e..3c1b874f 100644
--- a/packages/deepagent-code/test/config/config.test.ts
+++ b/packages/deepagent-code/test/config/config.test.ts
@@ -312,7 +312,7 @@ it.effect("creates global jsonc config with schema when no global configs exist"
Effect.gen(function* () {
yield* Config.use.get().pipe(provideInstanceEffect(dir))
- const content = yield* FSUtil.use.readFileString(path.join(dir, "deepagent-code.jsonc"))
+ const content = yield* FSUtil.use.readFileString(path.join(dir, "config.jsonc"))
expect(content).toContain('"$schema": "https://deepagent-code.ai/config.json"')
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
),
@@ -328,7 +328,7 @@ it.effect("does not create global config when DEEPAGENT_CODE_CONFIG_DIR is set",
Effect.gen(function* () {
yield* Config.use.get().pipe(provideInstanceEffect(dir))
- expect(yield* FSUtil.use.existsSafe(path.join(dir, "deepagent-code.jsonc"))).toBe(false)
+ expect(yield* FSUtil.use.existsSafe(path.join(dir, "config.jsonc"))).toBe(false)
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
),
)
@@ -675,7 +675,7 @@ it.effect("captures invalid fields in a global config file as a schema error", (
}),
)
-it.effect("consolidates legacy global config files into deepagent-code.jsonc and removes them", () =>
+it.effect("consolidates legacy global config files into config.jsonc and removes them", () =>
Effect.gen(function* () {
const globalDir = yield* tmpdirScoped()
const projectDir = yield* tmpdirScoped()
@@ -702,7 +702,7 @@ it.effect("consolidates legacy global config files into deepagent-code.jsonc and
// Legacy files are gone; the single canonical file remains.
expect(existsSync(path.join(globalDir, "config.json"))).toBe(false)
expect(existsSync(path.join(globalDir, "deepagent-code.json"))).toBe(false)
- expect(existsSync(path.join(globalDir, "deepagent-code.jsonc"))).toBe(true)
+ expect(existsSync(path.join(globalDir, "config.jsonc"))).toBe(true)
}),
),
)
@@ -732,9 +732,11 @@ it.effect("merges legacy keys into an existing .jsonc while preserving its comme
expect(config.model).toBe("canonical/wins")
expect(config.plugin).toContain("legacy-plugin")
- const text = yield* FSUtil.use.readFileString(path.join(globalDir, "deepagent-code.jsonc"))
+ // The legacy .jsonc is renamed to the canonical config.jsonc, carrying its comments over.
+ const text = yield* FSUtil.use.readFileString(path.join(globalDir, "config.jsonc"))
expect(text).toContain("// my providers")
expect(existsSync(path.join(globalDir, "deepagent-code.json"))).toBe(false)
+ expect(existsSync(path.join(globalDir, "deepagent-code.jsonc"))).toBe(false)
}),
),
)
diff --git a/packages/deepagent-code/test/effect/runtime-flags.test.ts b/packages/deepagent-code/test/effect/runtime-flags.test.ts
index b0cf0f26..b5802e11 100644
--- a/packages/deepagent-code/test/effect/runtime-flags.test.ts
+++ b/packages/deepagent-code/test/effect/runtime-flags.test.ts
@@ -75,35 +75,48 @@ describe("RuntimeFlags", () => {
}),
)
- it.effect("§H3: all seven V4.0 flags default OFF in production (staged rollout is operator opt-in)", () =>
+ it.effect("§H3 / V4.1: high-risk V4.0 capability flags default OFF; v4MultiAgentRuntime is promoted ON", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({})))
+ // The remaining high-risk / known-buggy capabilities stay operator opt-in (default OFF).
expect(flags.v4EventDrivenIm).toBe(false)
expect(flags.v4AgentPushEnabled).toBe(false)
- expect(flags.v4MultiAgentRuntime).toBe(false)
- expect(flags.v4AgentAutonomyLevel2).toBe(false)
expect(flags.v4ThreadEnabled).toBe(false)
expect(flags.v4FileUploadEnabled).toBe(false)
expect(flags.v4PanelAutoConvene).toBe(false)
+ // V4.1: the Multi-Agent Runtime master switch is PROMOTED ON — the daemon audit is GO and the §N
+ // event-driven goal-tick chain (with cross-process cold recovery) is now the live driver. Autonomous
+ // level_2 edits are the intended semantic (governed by agent descriptors, guarded by the safety gates).
+ expect(flags.v4MultiAgentRuntime).toBe(true)
}),
)
- it.effect("§H1: each V4.0 flag is an independent opt-in (=true enables just that one)", () =>
+ it.effect("V4.1: v4MultiAgentRuntime is a real kill-switch (=false restores the inert posture)", () =>
+ Effect.gen(function* () {
+ const flags = yield* readFlags.pipe(
+ Effect.provide(fromConfig({ DEEPAGENT_CODE_V4_MULTI_AGENT_RUNTIME: "false" })),
+ )
+ expect(flags.v4MultiAgentRuntime).toBe(false)
+ }),
+ )
+
+ it.effect("§H1: each remaining V4.0 flag is an independent opt-in (=true enables just that one)", () =>
Effect.gen(function* () {
// turning ONE on must not turn the others on — an operator advances the rollout capability by
// capability. This also proves the override path still works: the default is OFF but env `=true`
- // enables it (verification + staged rollout depend on this).
+ // enables it (verification + staged rollout depend on this). Uses v4AgentPushEnabled, which stays
+ // OFF-by-default after the V4.1 v4MultiAgentRuntime promotion.
const flags = yield* readFlags.pipe(
- Effect.provide(fromConfig({ DEEPAGENT_CODE_V4_MULTI_AGENT_RUNTIME: "true" })),
+ Effect.provide(fromConfig({ DEEPAGENT_CODE_V4_AGENT_PUSH_ENABLED: "true" })),
)
- expect(flags.v4MultiAgentRuntime).toBe(true)
+ expect(flags.v4AgentPushEnabled).toBe(true)
expect(flags.v4EventDrivenIm).toBe(false)
- expect(flags.v4AgentPushEnabled).toBe(false)
expect(flags.v4PanelAutoConvene).toBe(false)
+ expect(flags.v4ThreadEnabled).toBe(false)
}),
)
- it.effect("§H1: all seven V4.0 flags can be turned ON together via env (full-stack opt-in)", () =>
+ it.effect("§H1: all six V4.0 flags can be turned ON together via env (full-stack opt-in)", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(
Effect.provide(
@@ -111,7 +124,6 @@ describe("RuntimeFlags", () => {
DEEPAGENT_CODE_V4_EVENT_DRIVEN_IM: "true",
DEEPAGENT_CODE_V4_AGENT_PUSH_ENABLED: "true",
DEEPAGENT_CODE_V4_MULTI_AGENT_RUNTIME: "true",
- DEEPAGENT_CODE_V4_AGENT_AUTONOMY_LEVEL_2: "true",
DEEPAGENT_CODE_V4_THREAD_ENABLED: "true",
DEEPAGENT_CODE_V4_FILE_UPLOAD_ENABLED: "true",
DEEPAGENT_CODE_V4_PANEL_AUTO_CONVENE: "true",
@@ -121,7 +133,6 @@ describe("RuntimeFlags", () => {
expect(flags.v4EventDrivenIm).toBe(true)
expect(flags.v4AgentPushEnabled).toBe(true)
expect(flags.v4MultiAgentRuntime).toBe(true)
- expect(flags.v4AgentAutonomyLevel2).toBe(true)
expect(flags.v4ThreadEnabled).toBe(true)
expect(flags.v4FileUploadEnabled).toBe(true)
expect(flags.v4PanelAutoConvene).toBe(true)
diff --git a/packages/deepagent-code/test/provider/discovery-cache.test.ts b/packages/deepagent-code/test/provider/discovery-cache.test.ts
new file mode 100644
index 00000000..f5827409
--- /dev/null
+++ b/packages/deepagent-code/test/provider/discovery-cache.test.ts
@@ -0,0 +1,192 @@
+import { afterAll, beforeAll, describe, expect, test } from "bun:test"
+import { mkdtemp, rm } from "fs/promises"
+import os from "os"
+import path from "path"
+import { Duration, Effect, Layer } from "effect"
+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"
+
+// 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.
+let tmp: string
+let prevHome: string | undefined
+
+beforeAll(async () => {
+ tmp = await mkdtemp(path.join(os.tmpdir(), "discovery-cache-"))
+ prevHome = process.env.DEEPAGENT_CODE_HOME
+ process.env.DEEPAGENT_CODE_HOME = tmp
+})
+
+afterAll(async () => {
+ if (prevHome === undefined) delete process.env.DEEPAGENT_CODE_HOME
+ else process.env.DEEPAGENT_CODE_HOME = prevHome
+ await rm(tmp, { recursive: true, force: true })
+})
+
+const layer = Layer.mergeAll(FSUtil.defaultLayer, EffectFlock.defaultLayer)
+
+const run = (effect: (fs: FSUtil.Interface, flock: EffectFlock.Interface) => Effect.Effect ) =>
+ Effect.gen(function* () {
+ const fs = yield* FSUtil.Service
+ const flock = yield* EffectFlock.Service
+ return yield* effect(fs, flock)
+ }).pipe(Effect.provide(layer), Effect.scoped, Effect.runPromise)
+
+const model = (id: string): DiscoveredModel => ({ id, name: id.toUpperCase() })
+
+// Each test uses a distinct providerID/baseURL so their cache files don't collide.
+const baseInput = (id: string) => ({
+ providerID: id,
+ baseURL: `https://${id}.example.com`,
+ apiKey: "k",
+ kind: "openai-compatible" as const,
+})
+
+describe("discoverModelsCached", () => {
+ test("fetches and caches on a miss, then serves the cache without refetching", async () => {
+ let calls = 0
+ const fetch = async () => {
+ calls++
+ return [model("a"), model("b")]
+ }
+
+ const first = await run((fs, flock) => discoverModelsCached(fs, flock, baseInput("miss"), fetch))
+ expect(first.map((m) => m.id)).toEqual(["a", "b"])
+ expect(calls).toBe(1)
+
+ // Fresh cache (default 6h TTL) → no second fetch.
+ const second = await run((fs, flock) => discoverModelsCached(fs, flock, baseInput("miss"), fetch))
+ expect(second.map((m) => m.id)).toEqual(["a", "b"])
+ expect(calls).toBe(1)
+ })
+
+ test("refetches once the cache is stale (ttl elapsed)", async () => {
+ let calls = 0
+ const fetch = async () => {
+ calls++
+ return [model(`gen-${calls}`)]
+ }
+ const input = { ...baseInput("stale"), ttl: Duration.zero }
+
+ const first = await run((fs, flock) => discoverModelsCached(fs, flock, input, fetch))
+ expect(first.map((m) => m.id)).toEqual(["gen-1"])
+
+ // ttl=0 → cache is always stale → refetch.
+ const second = await run((fs, flock) => discoverModelsCached(fs, flock, input, fetch))
+ expect(second.map((m) => m.id)).toEqual(["gen-2"])
+ expect(calls).toBe(2)
+ })
+
+ test("falls back to the last good cache when a refetch fails", async () => {
+ let calls = 0
+ const fetch = async () => {
+ calls++
+ if (calls === 1) return [model("good")]
+ throw new Error("HTTP 500")
+ }
+ const input = { ...baseInput("fallback"), ttl: Duration.zero }
+
+ const first = await run((fs, flock) => discoverModelsCached(fs, flock, input, fetch))
+ expect(first.map((m) => m.id)).toEqual(["good"])
+
+ // Second call: cache stale, fetch throws → stale disk copy is returned instead of erroring.
+ const second = await run((fs, flock) => discoverModelsCached(fs, flock, input, fetch))
+ expect(second.map((m) => m.id)).toEqual(["good"])
+ expect(calls).toBe(2)
+ })
+
+ test("returns [] when there is no cache and the fetch fails", async () => {
+ const fetch = async () => {
+ throw new Error("HTTP 404")
+ }
+ const result = await run((fs, flock) => discoverModelsCached(fs, flock, baseInput("empty"), fetch))
+ expect(result).toEqual([])
+ })
+
+ test("does not cache a successful-but-empty result (no TTL pinning)", async () => {
+ let calls = 0
+ const fetch = async () => {
+ calls++
+ return calls === 1 ? [] : [model("late")]
+ }
+ // Fresh TTL: if the empty first result were cached, the second call would serve it and never see
+ // the model that came online.
+ const input = baseInput("empty-nocache")
+
+ const first = await run((fs, flock) => discoverModelsCached(fs, flock, input, fetch))
+ expect(first).toEqual([])
+
+ const second = await run((fs, flock) => discoverModelsCached(fs, flock, input, fetch))
+ expect(second.map((m) => m.id)).toEqual(["late"])
+ expect(calls).toBe(2)
+ })
+
+ test("empty refetch prefers the prior good cache over returning nothing", async () => {
+ let calls = 0
+ const fetch = async () => {
+ calls++
+ return calls === 1 ? [model("good")] : []
+ }
+ const input = { ...baseInput("empty-prefers-stale"), ttl: Duration.zero }
+
+ const first = await run((fs, flock) => discoverModelsCached(fs, flock, input, fetch))
+ expect(first.map((m) => m.id)).toEqual(["good"])
+
+ // Stale cache + empty refetch → keep serving the last good list.
+ const second = await run((fs, flock) => discoverModelsCached(fs, flock, input, fetch))
+ expect(second.map((m) => m.id)).toEqual(["good"])
+ })
+
+ test("filters non-chat models and caps id/name length", async () => {
+ const longId = "x".repeat(300)
+ const fetch = async (): Promise => [
+ model("gpt-4o"),
+ { id: "text-embedding-3-large", name: "Embed" },
+ { id: "whisper-1", name: "Whisper" },
+ { id: longId, name: "TooLong" },
+ { id: "claude-sonnet-4", name: "n".repeat(300) },
+ ]
+ const result = await run((fs, flock) => discoverModelsCached(fs, flock, baseInput("filter"), fetch))
+ // Non-chat ids dropped; over-length id dropped; chat models kept; name truncated to 256.
+ expect(result.map((m) => m.id).sort()).toEqual(["claude-sonnet-4", "gpt-4o"])
+ expect(result.find((m) => m.id === "claude-sonnet-4")!.name.length).toBe(256)
+ })
+
+ test("rotating the api key invalidates the cache (key is part of cache identity)", async () => {
+ let calls = 0
+ const fetch = async () => {
+ calls++
+ return [model(`key-${calls}`)]
+ }
+ const withKey = (apiKey: string) => ({ ...baseInput("rotate"), apiKey })
+
+ const first = await run((fs, flock) => discoverModelsCached(fs, flock, withKey("old-key"), fetch))
+ expect(first.map((m) => m.id)).toEqual(["key-1"])
+
+ // Same providerID+baseURL but a new key → different cache file → refetch, not a stale hit.
+ const second = await run((fs, flock) => discoverModelsCached(fs, flock, withKey("new-key"), fetch))
+ expect(second.map((m) => m.id)).toEqual(["key-2"])
+ expect(calls).toBe(2)
+ })
+
+ test("discovers with header-only auth (no api key)", async () => {
+ let received: DiscoverModelsCachedInput | undefined
+ const fetch = async (input: DiscoverModelsCachedInput) => {
+ received = input
+ return [model("hdr-a")]
+ }
+ const input: DiscoverModelsCachedInput = {
+ providerID: "header-only",
+ baseURL: "https://header-only.example.com",
+ kind: "openai-compatible",
+ headers: { "X-Api-Key": "in-header" },
+ }
+ const result = await run((fs, flock) => discoverModelsCached(fs, flock, input, fetch))
+ expect(result.map((m) => m.id)).toEqual(["hdr-a"])
+ expect(received?.apiKey).toBeUndefined()
+ expect(received?.headers).toEqual({ "X-Api-Key": "in-header" })
+ })
+})
diff --git a/packages/deepagent-code/test/provider/model-discovery.test.ts b/packages/deepagent-code/test/provider/model-discovery.test.ts
new file mode 100644
index 00000000..0e3668c5
--- /dev/null
+++ b/packages/deepagent-code/test/provider/model-discovery.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, test } from "bun:test"
+import { discoverWithProtocol, isChatModel, normalizeBaseURL } from "@/provider/model-discovery"
+
+describe("normalizeBaseURL", () => {
+ test("strips query, hash and trailing slashes", () => {
+ expect(normalizeBaseURL("https://api.example.com/v1/?x=1#frag")).toBe("https://api.example.com/v1")
+ })
+})
+
+describe("isChatModel", () => {
+ test("filters out non-chat model ids", () => {
+ expect(isChatModel("gpt-4o")).toBe(true)
+ expect(isChatModel("text-embedding-3-small")).toBe(false)
+ expect(isChatModel("whisper-1")).toBe(false)
+ expect(isChatModel("dall-e-3-image")).toBe(false)
+ })
+})
+
+describe("discoverWithProtocol", () => {
+ const input = { baseURL: "https://relay.example.com", apiKey: "k", providerID: "relay" }
+
+ test("uses the explicit kind without probing others", async () => {
+ const tried: string[] = []
+ const result = await discoverWithProtocol({ ...input, kind: "anthropic" }, async (kind) => {
+ tried.push(kind)
+ return [{ id: "claude-x", name: "Claude X" }]
+ })
+ expect(tried).toEqual(["anthropic"])
+ expect(result.kind).toBe("anthropic")
+ expect(result.models).toHaveLength(1)
+ })
+
+ test("probes openai-compatible first when kind omitted", async () => {
+ const tried: string[] = []
+ const result = await discoverWithProtocol(input, async (kind) => {
+ tried.push(kind)
+ return [{ id: "m", name: "M" }]
+ })
+ expect(tried).toEqual(["openai-compatible"])
+ expect(result.kind).toBe("openai-compatible")
+ })
+
+ test("falls back to anthropic when openai-compatible yields nothing", async () => {
+ const tried: string[] = []
+ const result = await discoverWithProtocol(input, async (kind) => {
+ tried.push(kind)
+ if (kind === "openai-compatible") return []
+ return [{ id: "claude-x", name: "Claude X" }]
+ })
+ expect(tried).toEqual(["openai-compatible", "anthropic"])
+ expect(result.kind).toBe("anthropic")
+ })
+
+ test("falls back to anthropic when openai-compatible throws", async () => {
+ const result = await discoverWithProtocol(input, async (kind) => {
+ if (kind === "openai-compatible") throw new Error("HTTP 404")
+ return [{ id: "claude-x", name: "Claude X" }]
+ })
+ expect(result.kind).toBe("anthropic")
+ })
+
+ test("throws the last error when every protocol fails", async () => {
+ await expect(
+ discoverWithProtocol(input, async (kind) => {
+ throw new Error(`fail-${kind}`)
+ }),
+ ).rejects.toThrow("fail-anthropic")
+ })
+})
diff --git a/packages/deepagent-code/test/provider/provider.test.ts b/packages/deepagent-code/test/provider/provider.test.ts
index fa946b3c..af74e78f 100644
--- a/packages/deepagent-code/test/provider/provider.test.ts
+++ b/packages/deepagent-code/test/provider/provider.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, expect, test } from "bun:test"
+import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"
import { mkdir, unlink } from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
@@ -17,6 +17,7 @@ import { Plugin } from "../../src/plugin/index"
import { Provider } from "@/provider/provider"
import { RuntimeFlags } from "@/effect/runtime-flags"
+import { EffectFlock } from "@deepagent-code/core/util/effect-flock"
import { Filesystem } from "@/util/filesystem"
import { InstanceLayer } from "@/project/instance-layer"
import { testEffect } from "../lib/effect"
@@ -77,6 +78,7 @@ const providerLayer = (flags: Partial = {}) =>
Layer.provide(Plugin.defaultLayer),
Layer.provide(ModelsDev.defaultLayer),
Layer.provide(RuntimeFlags.layer(flags)),
+ Layer.provide(EffectFlock.defaultLayer),
)
const list = Provider.use.list()
@@ -107,6 +109,41 @@ const connect = (providerID: ProviderV2.ID, key: string) =>
})
})
+// A tiny OpenAI-compatible /models endpoint so runtime discovery has something real to fetch. The
+// provider loader uses the live HTTP path (no injected stub), so an actual server is the cleanest
+// way to exercise the discovery pre-pass end to end.
+let discoveryServer: ReturnType | undefined
+let discoveryServerURL = ""
+let discoveryEmptyURL = ""
+
+beforeAll(() => {
+ discoveryServer = Bun.serve({
+ port: 0,
+ fetch(req) {
+ const url = new URL(req.url)
+ if (url.pathname.endsWith("/models")) {
+ // `/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: [] })
+ return Response.json({
+ data: [
+ { id: "runtime-a", display_name: "Runtime A" },
+ { id: "runtime-b", display_name: "Runtime B" },
+ { id: "text-embedding-3-large", display_name: "Embeddings" },
+ ],
+ })
+ }
+ return new Response("not found", { status: 404 })
+ },
+ })
+ discoveryServerURL = `http://localhost:${discoveryServer.port}/v1`
+ discoveryEmptyURL = `http://localhost:${discoveryServer.port}/empty`
+})
+
+afterAll(() => {
+ discoveryServer?.stop(true)
+})
+
const alphaProviderConfig = {
provider: {
"custom-provider": {
@@ -162,6 +199,90 @@ it.instance(
},
)
+it.instance(
+ "discovery:true third-party provider populates models from its /models endpoint at runtime",
+ Effect.gen(function* () {
+ const providers = yield* list
+ const provider = providers[ProviderV2.ID.make("runtime-disc")]
+ expect(provider).toBeDefined()
+ expect(provider.source).toBe("custom")
+ // Models come entirely from the live endpoint (config listed none). The embedding model the
+ // endpoint also returns is filtered out by isChatModel in the runtime pre-pass, so only the two
+ // chat models remain — proving a URL+key-only provider is no longer dropped for having zero models.
+ expect(Object.keys(provider.models).sort()).toEqual(["runtime-a", "runtime-b"])
+ expect(provider.models["runtime-a"].name).toBe("Runtime A")
+ expect(provider.models["text-embedding-3-large"]).toBeUndefined()
+ }),
+ {
+ config: () => ({
+ provider: {
+ "runtime-disc": {
+ name: "Runtime Discovery",
+ npm: "@ai-sdk/openai-compatible",
+ discovery: true,
+ options: { apiKey: "k", baseURL: discoveryServerURL },
+ },
+ },
+ }),
+ },
+)
+
+it.instance(
+ "manual models win over discovered models for the same id",
+ Effect.gen(function* () {
+ const providers = yield* list
+ const provider = providers[ProviderV2.ID.make("runtime-disc-manual")]
+ expect(provider).toBeDefined()
+ // Discovery returns runtime-a/runtime-b; config pins a custom name for runtime-a and adds an
+ // extra manual model. The manual name must survive the merge.
+ expect(provider.models["runtime-a"].name).toBe("Pinned A")
+ expect(provider.models["runtime-b"]).toBeDefined()
+ expect(provider.models["manual-only"].name).toBe("Manual Only")
+ }),
+ {
+ config: () => ({
+ provider: {
+ "runtime-disc-manual": {
+ name: "Runtime Discovery Manual",
+ npm: "@ai-sdk/openai-compatible",
+ discovery: true,
+ options: { apiKey: "k", baseURL: discoveryServerURL },
+ models: {
+ "runtime-a": { name: "Pinned A" },
+ "manual-only": { name: "Manual Only" },
+ },
+ },
+ },
+ }),
+ },
+)
+
+it.instance(
+ "discovery provider that yields no models surfaces a config error instead of vanishing silently",
+ Effect.gen(function* () {
+ const providers = yield* list
+ // The provider still can't load (no models to route to), but instead of disappearing with zero
+ // diagnostics it leaves a config error the UI can surface.
+ expect(providers[ProviderV2.ID.make("runtime-empty")]).toBeUndefined()
+ const errors = yield* Provider.use.errors()
+ const err = errors.find((e) => e.source === "provider.runtime-empty")
+ expect(err).toBeDefined()
+ expect(err?.message).toContain("discovery")
+ }),
+ {
+ config: () => ({
+ provider: {
+ "runtime-empty": {
+ name: "Runtime Empty",
+ npm: "@ai-sdk/openai-compatible",
+ discovery: true,
+ options: { apiKey: "k", baseURL: discoveryEmptyURL },
+ },
+ },
+ }),
+ },
+)
+
it.instance(
"config provider with official id reports duplicate provider error",
Effect.gen(function* () {
diff --git a/packages/deepagent-code/test/provider/transform.test.ts b/packages/deepagent-code/test/provider/transform.test.ts
index 5eb30896..98eaf240 100644
--- a/packages/deepagent-code/test/provider/transform.test.ts
+++ b/packages/deepagent-code/test/provider/transform.test.ts
@@ -3997,3 +3997,32 @@ describe("ProviderTransform.providerOptions - ai-gateway-provider", () => {
expect(result).toEqual({ openaiCompatible: { reasoningEffort: "high" } })
})
})
+
+describe("ProviderTransform.maxOutputTokens - per-model output cap (4c)", () => {
+ const modelWithOutput = (output: number | undefined) => ({ limit: { output } }) as any
+
+ test("no env override: uses the model's real output limit above legacy 32k", () => {
+ expect(ProviderTransform.maxOutputTokens(modelWithOutput(64_000))).toBe(64_000)
+ })
+
+ test("env override tightens a >32k model down to the cap", () => {
+ expect(ProviderTransform.maxOutputTokens(modelWithOutput(64_000), 40_000)).toBe(40_000)
+ })
+
+ test("no env override: model below legacy default is used as-is", () => {
+ expect(ProviderTransform.maxOutputTokens(modelWithOutput(8_000))).toBe(8_000)
+ })
+
+ test("no env override + model reports no output limit: falls back to legacy 32k", () => {
+ expect(ProviderTransform.maxOutputTokens(modelWithOutput(0))).toBe(ProviderTransform.OUTPUT_TOKEN_MAX)
+ expect(ProviderTransform.maxOutputTokens(modelWithOutput(undefined))).toBe(ProviderTransform.OUTPUT_TOKEN_MAX)
+ })
+
+ test("env override raising above the model cap cannot loosen it", () => {
+ expect(ProviderTransform.maxOutputTokens(modelWithOutput(8_000), 40_000)).toBe(8_000)
+ })
+
+ test("env override with no model limit falls back to the override value", () => {
+ expect(ProviderTransform.maxOutputTokens(modelWithOutput(0), 40_000)).toBe(40_000)
+ })
+})
diff --git a/packages/deepagent-code/test/server/httpapi-instance.test.ts b/packages/deepagent-code/test/server/httpapi-instance.test.ts
index 4605a4d0..d5c21d55 100644
--- a/packages/deepagent-code/test/server/httpapi-instance.test.ts
+++ b/packages/deepagent-code/test/server/httpapi-instance.test.ts
@@ -87,13 +87,13 @@ describe("instance HttpApi", () => {
sessions: true,
pty: true,
workspaces: true,
- // §H3 — the event-driven flags are advertised and default OFF in production (operator
- // opt-in per the staged rollout). This test builds RuntimeFlags from empty env, so the
- // capability endpoint reports the production defaults.
+ // §H3 / V4.1 — the event-driven capability flags are advertised; most stay default OFF (operator
+ // opt-in per the staged rollout), while v4MultiAgentRuntime is PROMOTED ON (the §N event-driven
+ // goal-tick chain is the live driver; daemon audit GO). This test builds RuntimeFlags from empty
+ // env, so the capability endpoint reports the production defaults.
v4EventDrivenIm: false,
v4AgentPushEnabled: false,
- v4MultiAgentRuntime: false,
- v4AgentAutonomyLevel2: false,
+ v4MultiAgentRuntime: true,
v4ThreadEnabled: false,
v4FileUploadEnabled: false,
},
diff --git a/packages/deepagent-code/test/server/httpapi-webhook.test.ts b/packages/deepagent-code/test/server/httpapi-webhook.test.ts
index fdfe88c6..560943ed 100644
--- a/packages/deepagent-code/test/server/httpapi-webhook.test.ts
+++ b/packages/deepagent-code/test/server/httpapi-webhook.test.ts
@@ -12,7 +12,7 @@
import { afterEach, describe, expect } from "bun:test"
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
-import { Config, Effect, Layer, Stream } from "effect"
+import { Config, ConfigProvider, Effect, Layer, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse, HttpRouter, HttpServer } from "effect/unstable/http"
import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket"
import { Flag } from "@deepagent-code/core/flag/flag"
@@ -58,6 +58,31 @@ const httpApiLayer = servedRoutes.pipe(
Layer.provideMerge(NodeServices.layer),
)
+// V4.1 — pin v4MultiAgentRuntime OFF for this file. It tests the §A1 INGRESS + persistence contract (the
+// endpoint persists exactly the webhook event, deterministic key, dedup), NOT the dispatch runtime. With
+// the runtime ON (the new production default), the EventDispatcher daemon inside the server graph would
+// consume each ingested event, the §E1 security gate would BLOCK the untrusted external source
+// (git/ci/pr/monitor — by design), and each block would publish a system `agent.task.blocked` fact into
+// the SAME workspace log — so `replayAll` would see those extra events and the "exactly the four webhook
+// events" assertion (and the TestClock-driven rate-limit case) would break on runtime behaviour this file
+// does not test. The daemon reads the flag from RuntimeFlags.defaultLayer DEEP inside the server graph, so
+// an outer Layer.provide override does NOT reach it — set the env var + inject a fresh ConfigProvider
+// (snapshotting env at BUILD time) so the deep default re-reads the pinned value. The runtime's own
+// consume/block behaviour is covered by the event-dispatcher / multi-agent-runtime suites.
+// Pin the flag via an injected ConfigProvider built from a COPY of process.env with the one key
+// overridden, so RuntimeFlags.defaultLayer — which reads from the ambient Effect ConfigProvider deep
+// inside the server graph — resolves v4MultiAgentRuntime to false for THIS test's layer scope only.
+// Crucially this does NOT write the global process.env (bun loads all test-file modules up-front, so a
+// top-level env write would leak the pinned value into other files that snapshot env). Built inside
+// Layer.suspend so the env copy is taken at BUILD time.
+const pinnedFlagProvider = Layer.suspend(() =>
+ ConfigProvider.layer(
+ ConfigProvider.fromEnv({
+ env: { ...(process.env as Record), DEEPAGENT_CODE_V4_MULTI_AGENT_RUNTIME: "false" },
+ }),
+ ),
+)
+
// The bus over the SAME file-backed DB the server writes to — used to replay + assert persisted events.
const it = testEffect(
Layer.mergeAll(
@@ -68,7 +93,7 @@ const it = testEffect(
Database.defaultLayer,
DeepAgentEventBus.defaultLayer,
httpApiLayer,
- ),
+ ).pipe(Layer.provide(pinnedFlagProvider)),
)
function request(path: string, init?: RequestInit) {
diff --git a/packages/deepagent-code/test/session/context-ledger.test.ts b/packages/deepagent-code/test/session/context-ledger.test.ts
index 2b47db58..ed3b0c9f 100644
--- a/packages/deepagent-code/test/session/context-ledger.test.ts
+++ b/packages/deepagent-code/test/session/context-ledger.test.ts
@@ -37,6 +37,27 @@ describe("parseSummaryToEntries (Stage 1)", () => {
expect(byKind("artifact")).toEqual(["src/foo.ts"])
})
+ // V4.0.1 P1 §3.4 — the narrowed four-bucket template's headings parse into the right ledger kinds; the
+ // "Data References" bucket → artifact (reference only, never content).
+ test("maps the narrowed four-bucket headings (Progress & Key Decisions / Data References)", () => {
+ const summary = [
+ "## Progress & Key Decisions",
+ "- chose DocumentStore for persistence",
+ "## Constraints & Preferences",
+ "- keep it backward compatible",
+ "## Next Steps",
+ "- wire the re-injection",
+ "## Data References",
+ "- src/foo.ts: the entry point",
+ ].join("\n")
+ const entries = parseSummaryToEntries(summary)
+ const byKind = (k: string) => entries.filter((e) => e.kind === k).map((e) => e.text)
+ expect(byKind("decision")).toEqual(["chose DocumentStore for persistence"])
+ expect(byKind("constraint")).toEqual(["keep it backward compatible"])
+ expect(byKind("next")).toEqual(["wire the re-injection"])
+ expect(byKind("artifact")).toEqual(["src/foo.ts: the entry point"])
+ })
+
test("skips placeholders and bullets outside a known heading", () => {
const summary = [
"# Goal",
diff --git a/packages/deepagent-code/test/session/goal-loop-wiring.test.ts b/packages/deepagent-code/test/session/goal-loop-wiring.test.ts
index 700c22f9..7af59ae5 100644
--- a/packages/deepagent-code/test/session/goal-loop-wiring.test.ts
+++ b/packages/deepagent-code/test/session/goal-loop-wiring.test.ts
@@ -41,6 +41,22 @@ const turnFrom = (over: Partial = {}): SubagentTurnResult =>
...over,
})
+// V4.0.1 P2 — the StepExecutor input now also carries the goal's ledger + limits (so the wiring can
+// thread a tiered cost soft-notice into the step-prompt tail). This helper builds a minimal valid input
+// for the buildStepExecutor unit tests; individual tests override ledger/limits when they exercise the
+// budget notice.
+const execInput = (
+ over: Partial>[0]> = {},
+): Parameters>[0] => ({
+ goalId: "g",
+ sessionId: "s",
+ planDocId: "p",
+ activeStepId: null,
+ ledger: { ticks: 0, tokens: 0, cost: 0, wallclockMs: 0, startedAtMs: 0 },
+ limits: { maxTicks: 100, maxTokens: 100_000, maxWallclockMs: 100_000 },
+ ...over,
+})
+
const reviewTurn = (result: ReviewResult): SubagentTurnRunner => () =>
Effect.succeed(turnFrom({ structured: result }))
@@ -179,25 +195,126 @@ describe("V3.9 §D wiring — GraderPorts.panelApproves (real runPanel + arbiter
describe("V3.9 §D wiring — buildStepExecutor", () => {
test("maps a good turn → tokens/cost, no critical", async () => {
const exec = buildStepExecutor(() => Effect.succeed(turnFrom({ ok: true, tokensUsed: 42, cost: 0.1 })))
- const res = await Effect.runPromise(
- exec({ goalId: "g", sessionId: "s", planDocId: "p", activeStepId: "a" }),
- )
+ const res = await Effect.runPromise(exec(execInput({ activeStepId: "a" })))
expect(res.tokensUsed).toBe(42)
expect(res.cost).toBe(0.1)
expect(res.critical).toBeUndefined()
})
test("a failed turn → critical (loop rolls back)", async () => {
const exec = buildStepExecutor(() => Effect.succeed(turnFrom({ ok: false })))
- const res = await Effect.runPromise(
- exec({ goalId: "g", sessionId: "s", planDocId: "p", activeStepId: null }),
- )
+ const res = await Effect.runPromise(exec(execInput()))
expect(res.critical).toBe(true)
})
test("a defect → critical, never thrown", async () => {
const exec = buildStepExecutor(() => Effect.die("boom"))
- const res = await Effect.runPromise(exec({ goalId: "g", sessionId: "s", planDocId: "p", activeStepId: null }))
+ const res = await Effect.runPromise(exec(execInput()))
expect(res.critical).toBe(true)
})
+
+ // V4.0.1 P2 §4.4 — tiered cost soft-notice threaded into the step-prompt TAIL.
+ test("budgetSoftNotify ON: a tick past the cost tier threads a BUDGET NOTICE into the prompt tail", async () => {
+ let seenPrompt = ""
+ const runTurn: SubagentTurnRunner = (input) => {
+ seenPrompt = input.prompt
+ return Effect.succeed(turnFrom({ ok: true }))
+ }
+ // 8/10 = 80% cost → crosses the default 0.7 tier.
+ const exec = buildStepExecutor(runTurn, undefined, undefined, true)
+ await Effect.runPromise(
+ exec(
+ execInput({
+ ledger: { ticks: 1, tokens: 0, cost: 8, wallclockMs: 0, startedAtMs: 0 },
+ limits: { maxTicks: 100, maxTokens: 100_000, maxWallclockMs: 100_000, maxCost: 10 },
+ }),
+ ),
+ )
+ expect(seenPrompt).toMatch(/BUDGET NOTICE/)
+ expect(seenPrompt).toMatch(/80% used/)
+ // The notice is in the TAIL (after the fixed advance instruction), never a prefix.
+ expect(seenPrompt.indexOf("BUDGET NOTICE")).toBeGreaterThan(seenPrompt.indexOf("Advance goal"))
+ })
+
+ test("budgetSoftNotify OFF: no notice is threaded even when the cost tier is crossed", async () => {
+ let seenPrompt = ""
+ const runTurn: SubagentTurnRunner = (input) => {
+ seenPrompt = input.prompt
+ return Effect.succeed(turnFrom({ ok: true }))
+ }
+ const exec = buildStepExecutor(runTurn, undefined, undefined, false)
+ await Effect.runPromise(
+ exec(
+ execInput({
+ ledger: { ticks: 1, tokens: 0, cost: 9, wallclockMs: 0, startedAtMs: 0 },
+ limits: { maxTicks: 100, maxTokens: 100_000, maxWallclockMs: 100_000, maxCost: 10 },
+ }),
+ ),
+ )
+ expect(seenPrompt).not.toMatch(/BUDGET NOTICE/)
+ })
+
+ // V4.0.1 P2 §4.4 — the real turn runner surfaces the granular breakdown feeding the NET-token ledger.
+ test("surfaces granular net-token fields (input/output/carriedPrefix) from a turn", async () => {
+ const exec = buildStepExecutor(() =>
+ Effect.succeed(turnFrom({ ok: true, tokensUsed: 100, inputTokens: 80, outputTokens: 20, carriedPrefixTokens: 60 })),
+ )
+ const res = await Effect.runPromise(exec(execInput()))
+ expect(res.tokensUsed).toBe(100)
+ expect(res.inputTokens).toBe(80)
+ expect(res.outputTokens).toBe(20)
+ expect(res.carriedPrefixTokens).toBe(60)
+ })
+
+ // V4.0.1 P1 §3.3 — the World State provider re-injects the latest volatile facts into the step-prompt
+ // TAIL every tick (P3(d) gate-free goal-worker recall). Ordering: World State BEFORE the budget notice.
+ test("worldStateProvider ON: the rendered block is threaded into the prompt tail, before the budget notice", async () => {
+ let seenPrompt = ""
+ const runTurn: SubagentTurnRunner = (input) => {
+ seenPrompt = input.prompt
+ return Effect.succeed(turnFrom({ ok: true }))
+ }
+ const provider = () => Effect.succeed("\n## Version Control\nbranch main\n ")
+ const exec = buildStepExecutor(runTurn, undefined, undefined, true, provider)
+ await Effect.runPromise(
+ exec(
+ execInput({
+ ledger: { ticks: 1, tokens: 0, cost: 8, wallclockMs: 0, startedAtMs: 0 },
+ limits: { maxTicks: 100, maxTokens: 100_000, maxWallclockMs: 100_000, maxCost: 10 },
+ }),
+ ),
+ )
+ expect(seenPrompt).toContain("")
+ expect(seenPrompt).toContain("branch main")
+ // World State rides the tail AFTER the advance instruction …
+ expect(seenPrompt.indexOf("")).toBeGreaterThan(seenPrompt.indexOf("Advance goal"))
+ // … and BEFORE the (more volatile) budget notice (most volatile content stays last).
+ expect(seenPrompt.indexOf("")).toBeLessThan(seenPrompt.indexOf("BUDGET NOTICE"))
+ })
+
+ test("worldStateProvider omitted ⇒ no World State block (byte-for-byte pre-V4.0.1)", async () => {
+ let seenPrompt = ""
+ const runTurn: SubagentTurnRunner = (input) => {
+ seenPrompt = input.prompt
+ return Effect.succeed(turnFrom({ ok: true }))
+ }
+ const exec = buildStepExecutor(runTurn, undefined, undefined, false)
+ await Effect.runPromise(exec(execInput()))
+ expect(seenPrompt).not.toContain("")
+ })
+
+ test("a defect in the World State provider never fails the tick (default-safe: '' ⇒ turn still runs)", async () => {
+ let ran = false
+ const runTurn: SubagentTurnRunner = () => {
+ ran = true
+ return Effect.succeed(turnFrom({ ok: true, tokensUsed: 7 }))
+ }
+ // The provider itself is default-safe in production; here it returns "" (as refreshWorldState does on
+ // any defect) and the tick proceeds normally.
+ const exec = buildStepExecutor(runTurn, undefined, undefined, false, () => Effect.succeed(""))
+ const res = await Effect.runPromise(exec(execInput()))
+ expect(ran).toBe(true)
+ expect(res.tokensUsed).toBe(7)
+ expect(res.critical).toBeUndefined()
+ })
})
describe("V3.9 §E F3 wiring — plan bridge (worker plan edits reach the goal plan doc)", () => {
@@ -303,9 +420,7 @@ describe("V3.9 §E F3 wiring — plan bridge (worker plan edits reach the goal p
return Effect.succeed(turnFrom({ ok: true, tokensUsed: 5, sessionID: childId }))
}
const exec = buildStepExecutor(runTurn, bridgeFor)
- const res = await Effect.runPromise(
- exec({ goalId: "g", sessionId: goalSession, planDocId, activeStepId: "a" }),
- )
+ const res = await Effect.runPromise(exec(execInput({ sessionId: goalSession, planDocId, activeStepId: "a" })))
expect(res.critical).toBeUndefined()
// The worker's advance is now visible in the GOAL plan doc (what the grader reads).
const goalPlan = JSON.parse(store.get(planDocId)!.body) as PlanDoc
diff --git a/packages/deepagent-code/test/session/goal-steer.test.ts b/packages/deepagent-code/test/session/goal-steer.test.ts
index 4ee7a647..33cb8419 100644
--- a/packages/deepagent-code/test/session/goal-steer.test.ts
+++ b/packages/deepagent-code/test/session/goal-steer.test.ts
@@ -147,6 +147,26 @@ describe("§S1.3 renderStepPrompt — mid-run steering threads into the step pro
expect(prompt).toContain("- skip step 3")
expect(prompt).toContain("- prefer the async API")
})
+
+ // V4.0.1 P1 §3.3 — the World State block rides the tail after the advance instruction, before the
+ // budget notice; omitting it is byte-for-byte the base prompt (so the flag OFF path is unchanged).
+ test("worldState is threaded into the tail after the advance line and before the budget notice", () => {
+ const prompt = renderStepPrompt({
+ ...base,
+ worldState: "\n## Version Control\nbranch main\n ",
+ budgetNotice: "预算已用 80% used",
+ })
+ expect(prompt).toContain("")
+ expect(prompt.indexOf("")).toBeGreaterThan(prompt.indexOf("Advance goal"))
+ expect(prompt.indexOf("")).toBeLessThan(prompt.indexOf("BUDGET NOTICE"))
+ })
+
+ test("no worldState ⇒ prompt is unchanged (base behaviour)", () => {
+ const withNull = renderStepPrompt({ ...base, worldState: null })
+ const without = renderStepPrompt(base)
+ expect(withNull).toBe(without)
+ expect(withNull).not.toContain("")
+ })
})
describe("§S1.3 goal-tick steering — absorb a steer between ticks, consumed exactly once", () => {
diff --git a/packages/deepagent-code/test/session/goal-tick-cold-recovery.test.ts b/packages/deepagent-code/test/session/goal-tick-cold-recovery.test.ts
new file mode 100644
index 00000000..aeda12f2
--- /dev/null
+++ b/packages/deepagent-code/test/session/goal-tick-cold-recovery.test.ts
@@ -0,0 +1,243 @@
+import { describe, expect, beforeEach, afterEach } from "bun:test"
+import { mkdtempSync, rmSync } from "node:fs"
+import { tmpdir } from "node:os"
+import path from "node:path"
+import { Effect, Layer } from "effect"
+import { GoalTickConsumer } from "../../src/session/goal-tick-consumer"
+import { recoverGoalTickRequest } from "../../src/session/goal-tick-port"
+import { GoalDriver } from "../../src/session/goal-driver"
+import { DeepAgentEventBus } from "@deepagent-code/core/deepagent/deepagent-event-bus"
+import { Database } from "@deepagent-code/core/database/database"
+import { RuntimeFlags } from "../../src/effect/runtime-flags"
+import { DocumentStore } from "@deepagent-code/core/deepagent/document-store"
+import {
+ makeGoalLoop,
+ readGoalTickCursor,
+ type ControllerDeps,
+ type GraderPorts,
+ type StepExecutor,
+ type RollbackPort,
+ type GoalSpec,
+} from "@deepagent-code/core/deepagent/goal-loop"
+import { createPlanDoc, planScope, type PlanDoc, type PlanStep } from "@deepagent-code/core/deepagent/plan-controller"
+import { testEffect } from "../lib/effect"
+
+// V4.1 §N COLD RECOVERY — the end-to-end proof that a goal survives a process restart on the event-driven
+// path. It asserts the CENTRAL claim of makeGoalTickPort: given ONLY the goal's durable run_context doc on
+// disk (NO in-memory control map, no live driver — the "second process" state), a fresh consumer with a
+// FRESH DocumentStore handle reconstructs the loop from {sessionID, goalId}, executes exactly ONE tick that
+// ADVANCES the durable state, and re-emits the next goal.tick.requested with the advanced seq.
+//
+// It exercises the REAL GoalTickConsumer + REAL DeepAgentEventBus (persistence/retry/dedup), driving them
+// with a runTick port that performs the SAME durable-state reconstruction the production port does — a
+// fresh store over the on-disk root, makeGoalLoop + runOneTick, then readGoalTickCursor for the next seq —
+// but with a stub grader/executor so the tick is deterministic and needs no session stack. The point under
+// test is the COLD path (disk-only reconstruction + seq monotonicity), not the LLM turn.
+
+let clock = 5_000
+const now = () => clock
+
+const database = Database.layerFromPath(":memory:")
+
+let root: string
+let executions: number
+const SESSION = "s-cold-1"
+
+beforeEach(() => {
+ root = mkdtempSync(path.join(tmpdir(), "deepagent-cold-"))
+ executions = 0
+})
+afterEach(() => rmSync(root, { recursive: true, force: true }))
+
+const step = (id: string, status: PlanStep["status"]): PlanStep => ({
+ step_id: id,
+ title: id,
+ status,
+ acceptance: null,
+ assigned_agent: null,
+ evidence: [],
+ note: null,
+})
+
+// Persist a plan doc on disk under the SAME scope the goal loop reads (run:).
+const putPlan = (rootDir: string, steps: PlanStep[]): string => {
+ const store = new DocumentStore(rootDir)
+ const plan = createPlanDoc(SESSION, "reach the goal", steps)
+ const doc = store.upsert({
+ type: "plan",
+ scope: planScope(SESSION),
+ description: `plan ${SESSION}`,
+ idSlug: `plan-${SESSION}`,
+ body: JSON.stringify(plan),
+ provenance: { source: "model", run_ref: planScope(SESSION) },
+ })
+ return doc.id
+}
+
+// A stub executor that ADVANCES the plan (marks the first pending step done) using a FRESH store handle —
+// exactly like the goal-worker mirror-back, so a tick makes real forward progress and bumps the version.
+const advancingExecutor = (rootDir: string): StepExecutor => (input) =>
+ Effect.sync(() => {
+ executions++
+ const store = new DocumentStore(rootDir)
+ const doc = store.get(input.planDocId)
+ if (doc) {
+ const plan = JSON.parse(doc.body) as PlanDoc
+ const next = plan.steps.find((s) => s.status !== "done")
+ if (next) {
+ const steps = plan.steps.map((s) => (s.step_id === next.step_id ? { ...s, status: "done" as const } : s))
+ store.update(input.planDocId, JSON.stringify({ ...plan, steps }))
+ }
+ }
+ return { tokensUsed: 5 }
+ })
+
+const passingPortsExceptPlan = (): GraderPorts => ({
+ runTests: () => Effect.succeed({ pass: true }),
+ diagnostics: () => Effect.succeed({ maxSeverity: null }),
+ reviewerClean: () => Effect.succeed({ pass: true }),
+ panelApproves: () => Effect.succeed({ decision: "approve" }),
+})
+
+const coldDeps = (rootDir: string): ControllerDeps => ({
+ store: new DocumentStore(rootDir), // FRESH handle — the cold-fiber reconstruction
+ ports: passingPortsExceptPlan(),
+ executor: advancingExecutor(rootDir),
+ rollback: (() => Effect.void) as RollbackPort,
+ now,
+})
+
+const spec = (planDocId: string): GoalSpec => ({
+ planDocId,
+ criteria: [{ kind: "plan_complete" }],
+ limits: { maxTicks: 100, maxTokens: 100_000, maxWallclockMs: 100_000 },
+ stallThreshold: 3,
+})
+
+// The runTick port under test: DISK-ONLY reconstruction (mirrors makeGoalTickPort's durable core). It
+// opens a fresh store over the on-disk root, rebuilds the loop, runs ONE tick, and reads the post-tick
+// cursor for the next command's seq/version. NO in-memory control map is consulted anywhere. Reads the
+// module-level `root` at CALL time (set by beforeEach) — the layer object is built once, but the port runs
+// per-test after the fresh tmpdir exists.
+const coldRunTick: GoalTickConsumer.GoalTickPort = (request) =>
+ Effect.gen(function* () {
+ const store = new DocumentStore(root)
+ const recovered = recoverGoalTickRequest(request, readGoalTickCursor(store, request.sessionID, request.goalId))
+ if (recovered === "invalid") return yield* Effect.die("goal tick request ahead of durable cursor")
+ if (recovered != null) return recovered
+ const deps = { ...coldDeps(root), store }
+ const handle = { goalId: request.goalId, planDocId: request.planDocId, sessionId: request.sessionID }
+ const result = yield* GoalDriver.runOneTick(makeGoalLoop(deps), { deps, handle })
+ const cursor = readGoalTickCursor(store, request.sessionID, request.goalId)
+ return {
+ progress: result.progress,
+ nextSeq: cursor?.seq ?? request.seq + 1,
+ nextExpectedPlanVersion: cursor?.planVersion ?? request.expectedPlanVersion,
+ }
+ })
+
+const busLayer = DeepAgentEventBus.layerWith({ now }).pipe(Layer.provideMerge(database))
+const flagLayer = RuntimeFlags.layer({ v4MultiAgentRuntime: true })
+const consumerLayer = GoalTickConsumer.layerWith({ runTick: coldRunTick, runLoop: false }).pipe(
+ Layer.provide(busLayer),
+ Layer.provide(flagLayer),
+)
+const layer = Layer.mergeAll(consumerLayer, busLayer, flagLayer, database)
+
+describe("V4.1 §N — goal-tick COLD recovery (disk-only reconstruction)", () => {
+ const it = testEffect(layer)
+
+ it.effect(
+ "state on disk only (no control map) → consumer reconstructs + executes one tick + re-emits advanced seq",
+ () =>
+ Effect.gen(function* () {
+ // ── phase 1: a PRIOR 'process' started the goal + persisted its state to disk, then vanished ──
+ const planDocId = putPlan(root, [step("a", "active"), step("b", "pending")])
+ const startedHandle = yield* makeGoalLoop(coldDeps(root)).start(spec(planDocId))
+ // Nothing else in memory — only the run_context doc on disk. Read its seq BEFORE the cold tick.
+ const before = readGoalTickCursor(new DocumentStore(root), SESSION, startedHandle.goalId)
+ expect(before).not.toBeNull()
+ expect(before!.seq).toBe(0) // ticks=0 + stall=0 at start
+
+ // ── phase 2: a COLD consumer (fresh bus, fresh everything) drives one tick from a command ──
+ const bus = yield* DeepAgentEventBus.Service
+ const consumer = yield* GoalTickConsumer.Service
+ const cmd = yield* bus.publish(
+ GoalTickConsumer.tickCommand({
+ sessionID: SESSION,
+ goalId: startedHandle.goalId,
+ planDocId,
+ seq: before!.seq,
+ expectedPlanVersion: before!.planVersion,
+ }),
+ )
+ yield* consumer.handle(cmd)
+
+ // ── assert: durable state ADVANCED (the cold tick executed against disk) ──
+ const after = readGoalTickCursor(new DocumentStore(root), SESSION, startedHandle.goalId)
+ expect(after).not.toBeNull()
+ expect(after!.seq).toBeGreaterThan(before!.seq) // ledger.ticks bumped by a real progress tick
+
+ // ── assert: the self-driving chain re-emitted the NEXT command with the advanced seq ──
+ // Re-publishing that exact key is a dedup no-op (same id) → proves the consumer already emitted it.
+ const probe = yield* bus.publish(
+ GoalTickConsumer.tickCommand({
+ sessionID: SESSION,
+ goalId: startedHandle.goalId,
+ planDocId,
+ seq: after!.seq,
+ expectedPlanVersion: after!.planVersion,
+ }),
+ )
+ expect(probe.idempotencyKey).toBe(`goal:tick:${startedHandle.goalId}:${after!.seq}`)
+ }),
+ )
+
+ it.effect("redelivery after a committed tick repairs one successor without executing again", () =>
+ Effect.gen(function* () {
+ const planDocId = putPlan(root, [step("a", "active"), step("b", "pending")])
+ const handle = yield* makeGoalLoop(coldDeps(root)).start(spec(planDocId))
+ const before = readGoalTickCursor(new DocumentStore(root), SESSION, handle.goalId)!
+ const bus = yield* DeepAgentEventBus.Service
+ const consumer = yield* GoalTickConsumer.Service
+ const request = {
+ sessionID: SESSION,
+ goalId: handle.goalId,
+ planDocId,
+ seq: before.seq,
+ expectedPlanVersion: before.planVersion,
+ }
+ const command = yield* bus.publish(GoalTickConsumer.tickCommand(request))
+
+ // Simulate a crash after runOneTick persisted its cursor but before the consumer published the
+ // successor or acked this delivery. Redelivery must repair the chain without running another tick.
+ yield* coldRunTick(request)
+ const after = readGoalTickCursor(new DocumentStore(root), SESSION, handle.goalId)!
+ yield* consumer.handle(command)
+ const firstSuccessor = yield* bus.publish(
+ GoalTickConsumer.tickCommand({
+ sessionID: SESSION,
+ goalId: handle.goalId,
+ planDocId,
+ seq: after.seq,
+ expectedPlanVersion: after.planVersion,
+ }),
+ )
+
+ yield* consumer.handle(command)
+ const repairedSuccessor = yield* bus.publish(
+ GoalTickConsumer.tickCommand({
+ sessionID: SESSION,
+ goalId: handle.goalId,
+ planDocId,
+ seq: after.seq,
+ expectedPlanVersion: after.planVersion,
+ }),
+ )
+
+ expect(executions).toBe(1)
+ expect(repairedSuccessor.id).toBe(firstSuccessor.id)
+ expect(readGoalTickCursor(new DocumentStore(root), SESSION, handle.goalId)?.seq).toBe(after.seq)
+ }),
+ )
+})
diff --git a/packages/deepagent-code/test/session/goal-tick-consumer.test.ts b/packages/deepagent-code/test/session/goal-tick-consumer.test.ts
new file mode 100644
index 00000000..ddf169c0
--- /dev/null
+++ b/packages/deepagent-code/test/session/goal-tick-consumer.test.ts
@@ -0,0 +1,188 @@
+import { describe, expect, test } from "bun:test"
+import { Effect, Layer } from "effect"
+import { GoalTickConsumer } from "../../src/session/goal-tick-consumer"
+import { recoverGoalTickRequest } from "../../src/session/goal-tick-port"
+import { DeepAgentEventBus } from "@deepagent-code/core/deepagent/deepagent-event-bus"
+import { DeepAgentEvent } from "@deepagent-code/core/deepagent/deepagent-event"
+import { LMNEvents } from "@deepagent-code/core/deepagent/lmn-events"
+import { Database } from "@deepagent-code/core/database/database"
+import { RuntimeFlags } from "../../src/effect/runtime-flags"
+import { testEffect } from "../lib/effect"
+
+// V4.1 §N — GoalTickConsumer bus-logic tests. These exercise the CONSUMER's command handling (execute
+// one tick via the injected runTick port → re-emit next / stop / dedup / nack) with a DETERMINISTIC stub
+// port, so the hardest, most bug-prone logic (the self-driving chain + idempotency + ack/nack) is proven
+// independently of the heavy cold-reconstruction wiring (which the full layer supplies in production).
+
+let clock = 1_000
+const now = () => clock
+
+const database = Database.layerFromPath(":memory:")
+
+// A stub runTick that returns a scripted sequence of progress results, recording each call.
+type StubResult = GoalTickConsumer.GoalTickPortResult
+const makeLayer = (opts: {
+ flag?: boolean
+ runTick: GoalTickConsumer.GoalTickPort
+}) => {
+ const busLayer = DeepAgentEventBus.layerWith({ now }).pipe(Layer.provideMerge(database))
+ const flagLayer = RuntimeFlags.layer({ v4MultiAgentRuntime: opts.flag ?? true })
+ const consumer = GoalTickConsumer.layerWith({ runTick: opts.runTick, runLoop: false }).pipe(
+ Layer.provide(busLayer),
+ Layer.provide(flagLayer),
+ )
+ return Layer.mergeAll(consumer, busLayer, flagLayer, database)
+}
+
+const seq0Command = (over?: Partial<{ sessionID: string; goalId: string; planDocId: string; seq: number }>) => ({
+ sessionID: "s1",
+ goalId: "g1",
+ planDocId: "plan-1",
+ seq: 0,
+ expectedPlanVersion: 0,
+ ...over,
+})
+
+// publish a goal.tick.requested with the seq-based idempotency key (mirrors tickCommand).
+const publishTick = (req: ReturnType) =>
+ Effect.gen(function* () {
+ const bus = yield* DeepAgentEventBus.Service
+ return yield* bus.publish(GoalTickConsumer.tickCommand(req))
+ })
+
+describe("GoalTickConsumer — command identity", () => {
+ test("resume seeds keep the durable cursor but use a fresh namespace", () => {
+ const request = seq0Command({ seq: 8 })
+ const first = GoalTickConsumer.resumeTickCommand(request)
+ const second = GoalTickConsumer.resumeTickCommand(request)
+
+ expect(first.payload).toMatchObject({ seq: 8 })
+ expect(first.idempotencyKey).toStartWith("goal:tick:g1:resume:dae_")
+ expect(second.idempotencyKey).not.toBe(first.idempotencyKey)
+ expect(GoalTickConsumer.tickCommand(request).idempotencyKey).toBe("goal:tick:g1:8")
+ })
+
+ test("durable cursor classifies committed and out-of-order deliveries", () => {
+ const request = seq0Command({ seq: 4 })
+ expect(recoverGoalTickRequest(request, { seq: 4, planVersion: 2, phase: "running" })).toBeNull()
+ expect(recoverGoalTickRequest(request, { seq: 5, planVersion: 3, phase: "running" })).toEqual({
+ progress: "continue",
+ nextSeq: 5,
+ nextExpectedPlanVersion: 3,
+ })
+ expect(recoverGoalTickRequest(request, { seq: 5, planVersion: 3, phase: "done" })).toEqual({
+ progress: "terminal",
+ nextSeq: 5,
+ nextExpectedPlanVersion: 3,
+ })
+ expect(recoverGoalTickRequest(request, { seq: 3, planVersion: 1, phase: "running" })).toBe("invalid")
+ })
+})
+
+describe("GoalTickConsumer — command handling", () => {
+ const it = testEffect(
+ makeLayer({
+ runTick: () => Effect.succeed({ progress: "continue", nextSeq: 1, nextExpectedPlanVersion: 1 } as StubResult),
+ }),
+ )
+
+ it.effect("continue → executes tick and re-emits the next goal.tick.requested (seq advanced)", () =>
+ Effect.gen(function* () {
+ const bus = yield* DeepAgentEventBus.Service
+ const consumer = yield* GoalTickConsumer.Service
+ const cmd = yield* publishTick(seq0Command())
+ yield* consumer.handle(cmd)
+ // the next command (seq=1) must have been published — a distinct idempotencyKey, so it's stored.
+ const next = yield* bus.getByID(cmd.id) // sanity: original exists
+ expect(next?.type).toBe(LMNEvents.GOAL_TICK_REQUESTED)
+ // re-emit produced a second stored event; assert by re-publishing the SAME next key is a dedup no-op
+ const dup = yield* bus.publish(
+ GoalTickConsumer.tickCommand(seq0Command({ seq: 1 })),
+ )
+ // if the consumer already emitted seq=1, this returns the SAME event id (dedup); prove the chain fired.
+ expect(dup.idempotencyKey).toBe("goal:tick:g1:1")
+ }),
+ )
+})
+
+describe("GoalTickConsumer — terminal stops the chain", () => {
+ let calls = 0
+ const it = testEffect(
+ makeLayer({
+ runTick: () =>
+ Effect.sync(() => {
+ calls++
+ return { progress: "terminal", nextSeq: 1, nextExpectedPlanVersion: 1 } as StubResult
+ }),
+ }),
+ )
+
+ it.effect("terminal → acks, does NOT re-emit a next command", () =>
+ Effect.gen(function* () {
+ const consumer = yield* GoalTickConsumer.Service
+ const cmd = yield* publishTick(seq0Command({ goalId: "gterm" }))
+ yield* consumer.handle(cmd)
+ // no next command for gterm should exist beyond the one we published.
+ const bus = yield* DeepAgentEventBus.Service
+ const next = yield* bus.getByID(cmd.id)
+ expect(next).toBeDefined()
+ // publishing seq=1 for gterm ourselves must be a FRESH insert (consumer did NOT already emit it).
+ const probe = yield* bus.publish(GoalTickConsumer.tickCommand(seq0Command({ goalId: "gterm", seq: 1 })))
+ expect(probe.idempotencyKey).toBe("goal:tick:gterm:1")
+ }),
+ )
+})
+
+describe("GoalTickConsumer — flag OFF drives nothing", () => {
+ let executed = 0
+ const it = testEffect(
+ makeLayer({
+ flag: false,
+ runTick: () =>
+ Effect.sync(() => {
+ executed++
+ return { progress: "continue", nextSeq: 1, nextExpectedPlanVersion: 1 } as StubResult
+ }),
+ }),
+ )
+
+ it.effect("flag OFF → acks and drives nothing (no tick executed)", () =>
+ Effect.gen(function* () {
+ const consumer = yield* GoalTickConsumer.Service
+ const cmd = yield* publishTick(seq0Command({ goalId: "goff" }))
+ yield* consumer.handle(cmd)
+ expect(executed).toBe(0) // flag off ⇒ runTick never called
+ }),
+ )
+})
+
+describe("GoalTickConsumer — bus dedups a duplicate command", () => {
+ const it = testEffect(
+ makeLayer({
+ runTick: () => Effect.succeed({ progress: "continue", nextSeq: 1, nextExpectedPlanVersion: 1 } as StubResult),
+ }),
+ )
+
+ it.effect("duplicate command (same seq key) is deduped by the bus → one stored event", () =>
+ Effect.gen(function* () {
+ const bus = yield* DeepAgentEventBus.Service
+ const a = yield* bus.publish(GoalTickConsumer.tickCommand(seq0Command({ goalId: "gdup" })))
+ const b = yield* bus.publish(GoalTickConsumer.tickCommand(seq0Command({ goalId: "gdup" })))
+ // same idempotencyKey goal:tick:gdup:0 → the bus returns the SAME persisted event (one row).
+ expect(b.id).toBe(a.id)
+ }),
+ )
+})
+
+describe("GoalTickConsumer — port failure nacks", () => {
+ const it = testEffect(makeLayer({ runTick: () => Effect.die("tick blew up") }))
+
+ it.effect("port failure → nacks (does not throw out of handle)", () =>
+ Effect.gen(function* () {
+ const consumer = yield* GoalTickConsumer.Service
+ const cmd = yield* publishTick(seq0Command({ goalId: "gfail" }))
+ // handle must NOT throw — it catches the defect and nacks internally.
+ yield* consumer.handle(cmd)
+ }),
+ )
+})
diff --git a/packages/deepagent-code/test/session/llm-native-recorded.test.ts b/packages/deepagent-code/test/session/llm-native-recorded.test.ts
index c1fff1c1..db0faa38 100644
--- a/packages/deepagent-code/test/session/llm-native-recorded.test.ts
+++ b/packages/deepagent-code/test/session/llm-native-recorded.test.ts
@@ -1,6 +1,7 @@
import { ConfigV1 } from "@deepagent-code/core/v1/config/config"
import { SessionV1 } from "@deepagent-code/core/v1/session"
import { FSUtil } from "@deepagent-code/core/fs-util"
+import { EffectFlock } from "@deepagent-code/core/util/effect-flock"
import { ModelsDev } from "@deepagent-code/core/models-dev"
import { HttpRecorder } from "@deepagent-code/http-recorder"
import { HttpRecorderInternal } from "@deepagent-code/http-recorder/internal"
@@ -280,6 +281,7 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) {
Layer.provide(Plugin.defaultLayer),
Layer.provide(ModelsDev.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
+ Layer.provide(EffectFlock.defaultLayer),
)
// Only the HTTP client is recorded; RequestExecutor and the deepagent-code LLM stack remain real.
const metadata = {
diff --git a/packages/deepagent-code/test/session/llm.test.ts b/packages/deepagent-code/test/session/llm.test.ts
index 31d1d1fb..d795cb61 100644
--- a/packages/deepagent-code/test/session/llm.test.ts
+++ b/packages/deepagent-code/test/session/llm.test.ts
@@ -30,6 +30,7 @@ import { Session as SessionNs } from "@/session/session"
import { ProviderV2 } from "@deepagent-code/core/provider"
import { ModelV2 } from "@deepagent-code/core/model"
import { FSUtil } from "@deepagent-code/core/fs-util"
+import { EffectFlock } from "@deepagent-code/core/util/effect-flock"
import { Env } from "@/env"
const it = testEffect(Layer.mergeAll(LLM.defaultLayer, Provider.defaultLayer, Auth.defaultLayer))
@@ -97,6 +98,7 @@ function officialProviderMocks(providerID: string, apiKey: string, baseURL: stri
Layer.provide(pluginMock),
Layer.provide(modelsDevMock),
Layer.provide(RuntimeFlags.layer(flags)),
+ Layer.provide(EffectFlock.defaultLayer),
)
return { authMock, pluginMock, providerLayer }
}
diff --git a/packages/deepagent-code/test/session/overflow.test.ts b/packages/deepagent-code/test/session/overflow.test.ts
new file mode 100644
index 00000000..a75215e0
--- /dev/null
+++ b/packages/deepagent-code/test/session/overflow.test.ts
@@ -0,0 +1,298 @@
+import { describe, expect, test } from "bun:test"
+import type { ConfigV1 } from "@deepagent-code/core/v1/config/config"
+import type { Provider } from "@/provider/provider"
+import { Option, Schema } from "effect"
+import {
+ overflowStatus,
+ softLandingDecision,
+ isOverflow,
+ usable,
+ outputContinuationMax,
+ initialSoftLandingState,
+ CompactionSoftLandingState,
+ OUTPUT_CONTINUATION_MAX,
+ REMINDER_FRACTION,
+ AUTO_COMPACT_FALLBACK_BUFFER,
+ REMINDER_DEBOUNCE_TURNS,
+} from "@/session/overflow"
+
+// A model whose `input` limit is the direct usable budget knob. reserved defaults to
+// min(COMPACTION_BUFFER=20_000, maxOutputTokens); with output=10_000 → reserved=10_000, so
+// usable() = input - 10_000. We choose input=110_000 → usable=100_000 for round numbers.
+function model(opts?: { context?: number; input?: number; output?: number }): Provider.Model {
+ return {
+ id: "test-model",
+ providerID: "test",
+ name: "Test",
+ limit: {
+ context: opts?.context ?? 200_000,
+ input: opts?.input ?? 110_000,
+ output: opts?.output ?? 10_000,
+ },
+ cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
+ capabilities: {
+ toolcall: true,
+ attachment: false,
+ reasoning: false,
+ temperature: true,
+ input: { text: true, image: false, audio: false, video: false },
+ output: { text: true, image: false, audio: false, video: false },
+ },
+ api: { npm: "@ai-sdk/anthropic" },
+ options: {},
+ } as Provider.Model
+}
+
+const cfg = (over?: Partial): ConfigV1.Info =>
+ (over ? { compaction: over } : {}) as ConfigV1.Info
+
+const tokensFor = (total: number) => ({
+ total,
+ input: total,
+ output: 0,
+ reasoning: 0,
+ cache: { read: 0, write: 0 },
+})
+
+describe("overflowStatus lines", () => {
+ const m = model()
+ const c = cfg()
+ const hard = usable({ cfg: c, model: m }) // 100_000
+ const soft = hard * REMINDER_FRACTION // 80_000
+ const fallback = hard - AUTO_COMPACT_FALLBACK_BUFFER // 88_000
+
+ test("computes the three monotonic lines", () => {
+ const st = overflowStatus({ cfg: c, model: m, tokens: 0 })
+ expect(st.hardLine).toBe(hard)
+ expect(st.softLine).toBe(soft)
+ expect(st.fallbackLine).toBe(fallback)
+ expect(st.softLine).toBeLessThan(st.fallbackLine)
+ expect(st.fallbackLine).toBeLessThan(st.hardLine)
+ })
+
+ test("phase across the four bands [0,soft)/[soft,fallback)/[fallback,hard)/[hard,∞)", () => {
+ expect(overflowStatus({ cfg: c, model: m, tokens: soft - 1 }).phase).toBe("ok")
+ expect(overflowStatus({ cfg: c, model: m, tokens: soft }).phase).toBe("reminder")
+ expect(overflowStatus({ cfg: c, model: m, tokens: fallback - 1 }).phase).toBe("reminder")
+ expect(overflowStatus({ cfg: c, model: m, tokens: fallback }).phase).toBe("fallback")
+ expect(overflowStatus({ cfg: c, model: m, tokens: hard - 1 }).phase).toBe("fallback")
+ expect(overflowStatus({ cfg: c, model: m, tokens: hard }).phase).toBe("hard")
+ expect(overflowStatus({ cfg: c, model: m, tokens: hard + 50_000 }).phase).toBe("hard")
+ })
+
+ test("softLanding=false collapses to only ok/hard (V4.1 equivalence)", () => {
+ const off = (tokens: number) => overflowStatus({ cfg: c, model: m, tokens, softLanding: false }).phase
+ expect(off(soft)).toBe("ok")
+ expect(off(fallback)).toBe("ok")
+ expect(off(hard - 1)).toBe("ok")
+ expect(off(hard)).toBe("hard")
+ // The hard line is byte-identical to isOverflow regardless of the softLanding layers.
+ expect(isOverflow({ cfg: c, model: m, tokens: tokensFor(hard - 1) })).toBe(false)
+ expect(isOverflow({ cfg: c, model: m, tokens: tokensFor(hard) })).toBe(true)
+ })
+
+ test("isOverflow == overflowStatus(...).phase === 'hard'", () => {
+ for (const t of [0, soft, fallback, hard - 1, hard, hard + 1]) {
+ const viaStatus = overflowStatus({ cfg: c, model: m, tokens: t }).phase === "hard"
+ const viaWrapper = isOverflow({ cfg: c, model: m, tokens: tokensFor(t) })
+ expect(viaWrapper).toBe(viaStatus)
+ }
+ })
+
+ test("autocompact disabled ⇒ always ok (no soft-landing without compaction)", () => {
+ const disabled = cfg({ auto: false })
+ for (const t of [0, soft, fallback, hard, hard + 100_000]) {
+ expect(overflowStatus({ cfg: disabled, model: m, tokens: t }).phase).toBe("ok")
+ }
+ })
+
+ test("context===0 model never overflows", () => {
+ const zero = model({ context: 0 })
+ expect(overflowStatus({ cfg: c, model: zero, tokens: 1_000_000 }).phase).toBe("ok")
+ })
+
+ test("prefixTokens deduction (BodyAfterPrefix): a larger prefix does NOT advance the lines", () => {
+ // Same raw token count, but growing the static prefix reduces `used`, so the phase de-escalates.
+ // The soft/fallback/hard LINES themselves are unchanged by prefixTokens.
+ const raw = hard // would be "hard" with prefixTokens=0
+ const noPrefix = overflowStatus({ cfg: c, model: m, tokens: raw, prefixTokens: 0 })
+ // raw=100_000 body-after-prefix=100_000 ⇒ hard. Deduct a 15_000 prefix ⇒ body=85_000 ⇒ fallback
+ // band (>= fallbackLine 88_000? no → 85_000 is in [soft 80k, fallback 88k) ⇒ reminder).
+ const withPrefix = overflowStatus({ cfg: c, model: m, tokens: raw, prefixTokens: 15_000 })
+ expect(noPrefix.phase).toBe("hard")
+ expect(withPrefix.used).toBe(raw - 15_000) // 85_000
+ expect(withPrefix.phase).toBe("reminder")
+ // The LINES are prefix-independent (a bigger prefix does NOT advance soft/hard).
+ expect(withPrefix.softLine).toBe(noPrefix.softLine)
+ expect(withPrefix.fallbackLine).toBe(noPrefix.fallbackLine)
+ expect(withPrefix.hardLine).toBe(noPrefix.hardLine)
+ })
+
+ test("prefixTokens never drives used negative", () => {
+ const st = overflowStatus({ cfg: c, model: m, tokens: 100, prefixTokens: 5_000 })
+ expect(st.used).toBe(0)
+ expect(st.phase).toBe("ok")
+ })
+})
+
+describe("softLandingDecision state machine", () => {
+ const m = model()
+ const c = cfg()
+ const hard = usable({ cfg: c, model: m })
+ const soft = hard * REMINDER_FRACTION
+ const fallback = hard - AUTO_COMPACT_FALLBACK_BUFFER
+ const status = (tokens: number) => overflowStatus({ cfg: c, model: m, tokens })
+
+ test("ok band ⇒ no action, state untouched", () => {
+ const state = initialSoftLandingState
+ const { action, nextState } = softLandingDecision({ status: status(soft - 1), state, step: 3 })
+ expect(action).toBe("none")
+ expect(nextState).toEqual(state)
+ })
+
+ test("reminder injects once then debounces for N turns", () => {
+ let state = initialSoftLandingState
+ const first = softLandingDecision({ status: status(soft), state, step: 5 })
+ expect(first.action).toBe("reminder")
+ expect(first.nextState.reminderDeliveredAtTurn).toBe(5)
+ state = first.nextState
+
+ // Within debounce window ⇒ suppressed.
+ const soon = softLandingDecision({ status: status(soft + 100), state, step: 5 + REMINDER_DEBOUNCE_TURNS - 1 })
+ expect(soon.action).toBe("none")
+
+ // After the debounce window ⇒ re-injects.
+ const later = softLandingDecision({ status: status(soft + 100), state, step: 5 + REMINDER_DEBOUNCE_TURNS })
+ expect(later.action).toBe("reminder")
+ expect(later.nextState.reminderDeliveredAtTurn).toBe(5 + REMINDER_DEBOUNCE_TURNS)
+ })
+
+ test("fallback injects exactly once per epoch (idempotent)", () => {
+ let state = initialSoftLandingState
+ const first = softLandingDecision({ status: status(fallback), state, step: 10 })
+ expect(first.action).toBe("fallback")
+ expect(first.nextState.autoCompactFallbackDelivered).toBe(true)
+ state = first.nextState
+
+ // Re-entering the fallback band in the SAME epoch ⇒ no second injection.
+ const again = softLandingDecision({ status: status(fallback + 500), state, step: 11 })
+ expect(again.action).toBe("none")
+ expect(again.nextState.autoCompactFallbackDelivered).toBe(true)
+
+ const yetAgain = softLandingDecision({ status: status(fallback + 1_000), state, step: 12 })
+ expect(yetAgain.action).toBe("none")
+ })
+
+ test("hard compaction bumps epoch and resets flags ⇒ next epoch can fallback again", () => {
+ // Start in an epoch that already delivered a fallback.
+ let state: CompactionSoftLandingState = {
+ windowEpoch: 0,
+ autoCompactFallbackDelivered: true,
+ reminderDeliveredAtTurn: 4,
+ }
+ const hardDecision = softLandingDecision({ status: status(hard), state, step: 20 })
+ expect(hardDecision.action).toBe("hard")
+ expect(hardDecision.nextState.windowEpoch).toBe(1)
+ expect(hardDecision.nextState.autoCompactFallbackDelivered).toBe(false)
+ expect(hardDecision.nextState.reminderDeliveredAtTurn).toBeUndefined()
+ state = hardDecision.nextState
+
+ // New generation ⇒ fallback fires again.
+ const nextFallback = softLandingDecision({ status: status(fallback), state, step: 21 })
+ expect(nextFallback.action).toBe("fallback")
+ expect(nextFallback.nextState.windowEpoch).toBe(1)
+ expect(nextFallback.nextState.autoCompactFallbackDelivered).toBe(true)
+ })
+})
+
+describe("CompactionSoftLandingState durability (cold recovery)", () => {
+ // The state is stored on session metadata (Record), which round-trips through JSON in
+ // the DB. prompt.ts reads it back with Schema.decodeUnknownOption — this asserts the schema tolerates
+ // the plain-object form that survives serialization, so a cold consumer rebuilds the exact generation.
+ const decode = Schema.decodeUnknownOption(CompactionSoftLandingState)
+
+ test("round-trips a fully-populated state through a JSON-like plain object", () => {
+ const state: CompactionSoftLandingState = {
+ windowEpoch: 3,
+ autoCompactFallbackDelivered: true,
+ reminderDeliveredAtTurn: 7,
+ }
+ const roundTripped = JSON.parse(JSON.stringify(state))
+ const decoded = decode(roundTripped)
+ expect(Option.isSome(decoded)).toBe(true)
+ expect(Option.getOrThrow(decoded)).toEqual(state)
+ })
+
+ test("round-trips the initial state (optional field absent)", () => {
+ const roundTripped = JSON.parse(JSON.stringify(initialSoftLandingState))
+ const decoded = decode(roundTripped)
+ expect(Option.isSome(decoded)).toBe(true)
+ expect(Option.getOrThrow(decoded).windowEpoch).toBe(0)
+ expect(Option.getOrThrow(decoded).autoCompactFallbackDelivered).toBe(false)
+ })
+
+ test("garbage / missing metadata decodes to None (caller falls back to initial)", () => {
+ expect(Option.isNone(decode(undefined))).toBe(true)
+ expect(Option.isNone(decode({ windowEpoch: "nope" }))).toBe(true)
+ expect(Option.isNone(decode({}))).toBe(true) // autoCompactFallbackDelivered required
+ })
+
+ test("round-trips the new optional fields (prefillInputTokens + outputContinuationCount)", () => {
+ const state = { windowEpoch: 2, autoCompactFallbackDelivered: true, prefillInputTokens: 45_000, outputContinuationCount: 1 }
+ const decoded = decode(JSON.parse(JSON.stringify(state)))
+ expect(Option.isSome(decoded)).toBe(true)
+ const v = Option.getOrThrow(decoded)
+ expect(v.prefillInputTokens).toBe(45_000)
+ expect(v.outputContinuationCount).toBe(1)
+ })
+})
+
+// V4.0.1 P0 BodyAfterPrefix — the full-window SAFETY CAP that guards the raw total once a prefix is
+// deducted, so a huge prefix cannot let real usage silently exceed the model's input window.
+describe("overflowStatus full-window safety cap (BodyAfterPrefix)", () => {
+ const m = model() // input=110_000, usable/hardLine=100_000
+ const c = cfg()
+
+ test("raw total at/over the model input window forces hard even when body-after-prefix is small", () => {
+ // A 90k prefix makes body tiny, but raw total 110_000 hits the real input window ⇒ hard (safety cap).
+ const st = overflowStatus({ cfg: c, model: m, tokens: 110_000, prefixTokens: 90_000 })
+ expect(st.used).toBe(20_000) // body is well under every line
+ expect(st.phase).toBe("hard") // ...but the raw total hit the full window
+ })
+
+ test("safety cap is inert when prefix=0 (byte-for-byte pre-BodyAfterPrefix)", () => {
+ // With no prefix, used==raw and the cap adds nothing: below soft(80k) is ok, [soft,fallback) reminder.
+ expect(overflowStatus({ cfg: c, model: m, tokens: 70_000, prefixTokens: 0 }).phase).toBe("ok")
+ expect(overflowStatus({ cfg: c, model: m, tokens: 85_000, prefixTokens: 0 }).phase).toBe("reminder")
+ })
+
+ test("body-after-prefix stays the primary trigger below the full window", () => {
+ // raw 95_000 < fullWindow 110_000, prefix 10_000 ⇒ body 85_000 ⇒ reminder (not forced hard).
+ const st = overflowStatus({ cfg: c, model: m, tokens: 95_000, prefixTokens: 10_000 })
+ expect(st.used).toBe(85_000)
+ expect(st.phase).toBe("reminder")
+ })
+})
+
+// V4.0.1 P0b OUTPUT soft-landing — the continuation cap knob.
+describe("outputContinuationMax", () => {
+ test("defaults to OUTPUT_CONTINUATION_MAX when the env is unset/invalid", () => {
+ const saved = process.env["DEEPAGENT_CODE_OUTPUT_CONTINUATION_MAX"]
+ delete process.env["DEEPAGENT_CODE_OUTPUT_CONTINUATION_MAX"]
+ expect(outputContinuationMax()).toBe(OUTPUT_CONTINUATION_MAX)
+ process.env["DEEPAGENT_CODE_OUTPUT_CONTINUATION_MAX"] = "not-a-number"
+ expect(outputContinuationMax()).toBe(OUTPUT_CONTINUATION_MAX)
+ if (saved === undefined) delete process.env["DEEPAGENT_CODE_OUTPUT_CONTINUATION_MAX"]
+ else process.env["DEEPAGENT_CODE_OUTPUT_CONTINUATION_MAX"] = saved
+ })
+
+ test("honors a valid non-negative integer override (including 0 = disabled)", () => {
+ const saved = process.env["DEEPAGENT_CODE_OUTPUT_CONTINUATION_MAX"]
+ process.env["DEEPAGENT_CODE_OUTPUT_CONTINUATION_MAX"] = "5"
+ expect(outputContinuationMax()).toBe(5)
+ process.env["DEEPAGENT_CODE_OUTPUT_CONTINUATION_MAX"] = "0"
+ expect(outputContinuationMax()).toBe(0)
+ if (saved === undefined) delete process.env["DEEPAGENT_CODE_OUTPUT_CONTINUATION_MAX"]
+ else process.env["DEEPAGENT_CODE_OUTPUT_CONTINUATION_MAX"] = saved
+ })
+})
diff --git a/packages/deepagent-code/test/session/prompt.test.ts b/packages/deepagent-code/test/session/prompt.test.ts
index aff995e9..8d286a9f 100644
--- a/packages/deepagent-code/test/session/prompt.test.ts
+++ b/packages/deepagent-code/test/session/prompt.test.ts
@@ -756,6 +756,38 @@ it.instance("loop continues when finish is tool-calls", () =>
}),
)
+// V4.0.1 P0b OUTPUT soft-landing — a length-capped response continues instead of ending the turn.
+it.instance("loop continues (not exits) when finish is length: injects a continue nudge + re-prompts", () =>
+ Effect.gen(function* () {
+ const { llm } = yield* useServerConfig(providerCfg)
+ const prompt = yield* SessionPrompt.Service
+ const sessions = yield* Session.Service
+ const session = yield* sessions.create({
+ title: "Pinned",
+ permission: [{ permission: "*", pattern: "*", action: "allow" }],
+ })
+ // Seed an assistant that was cut off at the output ceiling (finish === "length"), then queue the
+ // continuation the re-prompt will consume. Without output soft-landing this would exit immediately
+ // like a "stop" finish (0 LLM calls); with it ON the loop injects a continue nudge and re-prompts.
+ yield* seed(session.id, { finish: "length" })
+ yield* llm.text("...continued to the end.")
+
+ const result = yield* prompt.loop({ sessionID: session.id })
+
+ // It made an LLM request (continued) rather than exiting on the length cutoff.
+ expect(yield* llm.hits).toHaveLength(1)
+ expect(result.info.role).toBe("assistant")
+ if (result.info.role === "assistant") expect(result.info.finish).toBe("stop")
+
+ // A synthetic continue-nudge user message was injected before the re-prompt.
+ const msgs = yield* sessions.messages({ sessionID: session.id })
+ const injected = msgs.some((m) =>
+ m.parts.some((p) => p.type === "text" && p.synthetic === true && p.text.includes("输出长度上限")),
+ )
+ expect(injected).toBe(true)
+ }),
+)
+
it.instance("glob tool keeps instance context during prompt runs", () =>
Effect.gen(function* () {
const { dir, llm } = yield* useServerConfig(providerCfg)
diff --git a/packages/deepagent-code/test/session/v4-integration.test.ts b/packages/deepagent-code/test/session/v4-integration.test.ts
index 4e1d9a8f..7d82e4bc 100644
--- a/packages/deepagent-code/test/session/v4-integration.test.ts
+++ b/packages/deepagent-code/test/session/v4-integration.test.ts
@@ -59,7 +59,6 @@ const makeLayer = (flags?: Partial) => {
v4EventDrivenIm: true,
v4AgentPushEnabled: true,
v4MultiAgentRuntime: true,
- v4AgentAutonomyLevel2: true,
...flags,
})
const core = Layer.mergeAll(
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index ca35e76c..f8b34658 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepagent-code/desktop",
"private": true,
- "version": "1.4.0",
+ "version": "1.4.1",
"type": "module",
"license": "AGPL-3.0-or-later",
"homepage": "https://deepagent-code.ai",
diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts
index 94b79c44..ffd66cfa 100644
--- a/packages/sdk/js/src/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/gen/sdk.gen.ts
@@ -1,7 +1,13 @@
// This file is auto-generated by @hey-api/openapi-ts
import { client } from "./client.gen.js"
-import { buildClientParams, type Client, type Options as Options2, type TDataShape } from "./client/index.js"
+import {
+ buildClientParams,
+ type Client,
+ formDataBodySerializer,
+ type Options as Options2,
+ type TDataShape,
+} from "./client/index.js"
import type {
AgentPartInput,
AppAgentsErrors,
@@ -58,6 +64,20 @@ import type {
DeepagentEnvFactsListResponses,
DeepagentEnvFactsModifyErrors,
DeepagentEnvFactsModifyResponses,
+ DeepagentGoalEditPlanErrors,
+ DeepagentGoalEditPlanResponses,
+ DeepagentGoalPauseErrors,
+ DeepagentGoalPauseResponses,
+ DeepagentGoalResumeErrors,
+ DeepagentGoalResumeResponses,
+ DeepagentGoalStartableErrors,
+ DeepagentGoalStartableResponses,
+ DeepagentGoalStartErrors,
+ DeepagentGoalStartResponses,
+ DeepagentGoalStatusErrors,
+ DeepagentGoalStatusResponses,
+ DeepagentGoalStopErrors,
+ DeepagentGoalStopResponses,
DeepagentKnowledgeApproveErrors,
DeepagentKnowledgeApproveResponses,
DeepagentKnowledgePendingErrors,
@@ -78,8 +98,24 @@ import type {
DeepagentPacksPinResponses,
DeepagentPacksUnpinErrors,
DeepagentPacksUnpinResponses,
+ DeepagentPanelArmErrors,
+ DeepagentPanelArmResponses,
+ DeepagentPanelConsultErrors,
+ DeepagentPanelConsultResponses,
+ DeepagentPanelStatusErrors,
+ DeepagentPanelStatusResponses,
DeepagentReviewsErrors,
DeepagentReviewsResponses,
+ DeepagentWikiEditErrors,
+ DeepagentWikiEditResponses,
+ DeepagentWikiExecutionArchiveErrors,
+ DeepagentWikiExecutionArchiveResponses,
+ DeepagentWikiPageErrors,
+ DeepagentWikiPageResponses,
+ DeepagentWikiPagesErrors,
+ DeepagentWikiPagesResponses,
+ DeepagentWikiSearchErrors,
+ DeepagentWikiSearchResponses,
EventSubscribeResponse,
EventSubscribeResponses,
EventTuiCommandExecute,
@@ -181,6 +217,10 @@ import type {
GlobalUpgradeResponses,
ImAgentsListErrors,
ImAgentsListResponses,
+ ImAttachmentsListErrors,
+ ImAttachmentsListResponses,
+ ImAttachmentsUploadErrors,
+ ImAttachmentsUploadResponses,
ImGroupsCreateErrors,
ImGroupsCreateResponses,
ImGroupsListErrors,
@@ -193,6 +233,10 @@ import type {
ImMessagesListResponses,
ImMessagesMarkReadErrors,
ImMessagesMarkReadResponses,
+ ImMessagesSearchErrors,
+ ImMessagesSearchResponses,
+ ImMessagesThreadErrors,
+ ImMessagesThreadResponses,
ImWebsocketConnectResponses,
InstanceDisposeErrors,
InstanceDisposeResponses,
@@ -240,6 +284,18 @@ import type {
McpStatusResponses,
MoveSessionDestination,
OutputFormat,
+ OversightApprovalsErrors,
+ OversightApprovalsResolveErrors,
+ OversightApprovalsResolveResponses,
+ OversightApprovalsResponses,
+ OversightMetricsErrors,
+ OversightMetricsResponses,
+ OversightRollbackErrors,
+ OversightRollbackResponses,
+ OversightTakeoverErrors,
+ OversightTakeoverResponses,
+ OversightTraceErrors,
+ OversightTraceResponses,
Part as Part2,
PartDeleteErrors,
PartDeleteResponses,
@@ -464,6 +520,14 @@ import type {
VcsGetResponses,
VcsStatusErrors,
VcsStatusResponses,
+ WebhookCiErrors,
+ WebhookCiResponses,
+ WebhookGitErrors,
+ WebhookGitResponses,
+ WebhookMonitorErrors,
+ WebhookMonitorResponses,
+ WebhookPrErrors,
+ WebhookPrResponses,
WorktreeChangesErrors,
WorktreeChangesResponses,
WorktreeCreateErrors,
@@ -2548,16 +2612,911 @@ export class EnvFacts extends HeyApiClient {
}
}
+export class Panel extends HeyApiClient {
+ /**
+ * Convene the Expert Panel (会诊)
+ *
+ * V3.9 §C: freeze the question, fan out the lens panelists (equal-footing debate), and return the deterministic arbiter verdict. Gated by the expert-panel flag.
+ */
+ public consult(
+ parameters: {
+ directory?: string
+ workspace?: string
+ sessionID: string
+ question?: string
+ codeRefs?: Array
+ lenses?: Array<"correctness" | "security" | "performance" | "architecture" | "repro">
+ maxRounds?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ policy?: "default" | "security"
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "sessionID" },
+ { in: "body", key: "question" },
+ { in: "body", key: "codeRefs" },
+ { in: "body", key: "lenses" },
+ { in: "body", key: "maxRounds" },
+ { in: "body", key: "policy" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post<
+ DeepagentPanelConsultResponses,
+ DeepagentPanelConsultErrors,
+ ThrowOnError
+ >({
+ url: "/deepagent/panel/consult",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * Arm or disarm the Expert Panel for a session
+ *
+ * V3.9 §C: per-conversation toggle for the panel button; seeded from the global default.
+ */
+ public arm(
+ parameters: {
+ directory?: string
+ workspace?: string
+ sessionID: string
+ armed: boolean
+ rounds?: "single" | "multi"
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "sessionID" },
+ { in: "body", key: "armed" },
+ { in: "body", key: "rounds" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/deepagent/panel/arm",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * Resolve the effective Expert Panel armed state
+ *
+ * V3.9 §C: returns the explicit per-session toggle if set, else the global expertPanelDefault — so the UI seeds the button from the server's default without guessing.
+ */
+ public status(
+ parameters: {
+ directory?: string
+ workspace?: string
+ sessionID: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "query", key: "sessionID" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get<
+ DeepagentPanelStatusResponses,
+ DeepagentPanelStatusErrors,
+ ThrowOnError
+ >({
+ url: "/deepagent/panel/status",
+ ...options,
+ ...params,
+ })
+ }
+}
+
+export class Goal extends HeyApiClient {
+ /**
+ * Start a Goal Loop from the session's plan
+ *
+ * V3.9 §D: materialize the session plan into the graded doc and drive the autonomous loop as a resident background task. Gated by the goal-loop flag.
+ */
+ public start(
+ parameters: {
+ directory?: string
+ workspace?: string
+ sessionID: string
+ objective?: string
+ criteria?: Array<{
+ kind: "tests_pass" | "no_diagnostics" | "reviewer_clean" | "panel_approves" | "plan_complete"
+ commands?: Array
+ maxSeverity?: string
+ severityAtMost?: string
+ }>
+ limits?: {
+ maxTicks?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ maxTokens?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ maxWallclockMs?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ maxCost?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ }
+ stallThreshold?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "sessionID" },
+ { in: "body", key: "objective" },
+ { in: "body", key: "criteria" },
+ { in: "body", key: "limits" },
+ { in: "body", key: "stallThreshold" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/deepagent/goal/start",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * Hot-edit the plan of a running/paused Goal Loop
+ *
+ * V4.1 §S2: enqueue a user plan revision on the goal control channel. The driver applies it between ticks (durable-doc upsert + stall re-baseline). ok:false when no goal is running or it reached a terminal phase.
+ */
+ public editPlan(
+ parameters: {
+ directory?: string
+ workspace?: string
+ sessionID: string
+ plan: {
+ goal: string
+ steps: Array<{
+ step_id?: string
+ title: string
+ status?: string
+ acceptance?: string
+ assigned_agent?: string
+ note?: string
+ }>
+ assumptions?: Array
+ active_step_id?: string
+ }
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "sessionID" },
+ { in: "body", key: "plan" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post<
+ DeepagentGoalEditPlanResponses,
+ DeepagentGoalEditPlanErrors,
+ ThrowOnError
+ >({
+ url: "/deepagent/goal/edit-plan",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+}
+
+export class Wiki extends HeyApiClient {
+ /**
+ * List Repo & Wiki pages
+ *
+ * V3.9 §B: the human-facing projection of the four graphs. Gated by the wiki flag.
+ */
+ public pages(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ type?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "query", key: "type" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/deepagent/wiki/pages",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Governed edit of a Knowledge/Memory Wiki page
+ *
+ * V3.9 §B.3: append-only new version through the real promotion evidence-gate + human provenance. Non-editable type ⇒ read-only error.
+ */
+ public edit(
+ parameters: {
+ directory?: string
+ workspace?: string
+ docId: string
+ scope: string
+ body: string
+ editor: {
+ id: string
+ name?: string
+ }
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "docId" },
+ { in: "body", key: "scope" },
+ { in: "body", key: "body" },
+ { in: "body", key: "editor" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/deepagent/wiki/edit",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * Read a session's execution archive
+ *
+ * V3.9 §B.6 read side: aggregate a completed session's run-scoped Document Graph trajectory into a single archive (markdown + entries). Gated by the wiki flag.
+ */
+ public executionArchive(
+ parameters: {
+ directory?: string
+ workspace?: string
+ sessionID: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "query", key: "sessionID" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get<
+ DeepagentWikiExecutionArchiveResponses,
+ DeepagentWikiExecutionArchiveErrors,
+ ThrowOnError
+ >({
+ url: "/deepagent/wiki/execution-archive",
+ ...options,
+ ...params,
+ })
+ }
+}
+
export class Deepagent extends HeyApiClient {
/**
- * List recent DeepAgent run reviews
+ * List recent DeepAgent run reviews
+ *
+ * Project recent DeepAgent run control-plane artifacts into reviewer views.
+ */
+ public reviews(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/deepagent/reviews",
+ ...options,
+ ...params,
+ })
+ }
+
+ public packsActive(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get<
+ DeepagentPacksActiveResponses,
+ DeepagentPacksActiveErrors,
+ ThrowOnError
+ >({
+ url: "/deepagent/packs/active",
+ ...options,
+ ...params,
+ })
+ }
+
+ public packsAll(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/deepagent/packs/all",
+ ...options,
+ ...params,
+ })
+ }
+
+ public packsPin(
+ parameters: {
+ directory?: string
+ workspace?: string
+ packId: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "packId" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/deepagent/packs/pin",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ public packsUnpin(
+ parameters: {
+ directory?: string
+ workspace?: string
+ packId: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "packId" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post(
+ {
+ url: "/deepagent/packs/unpin",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ },
+ )
+ }
+
+ public goalPause(
+ parameters: {
+ directory?: string
+ workspace?: string
+ sessionID: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "sessionID" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/deepagent/goal/pause",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ public goalResume(
+ parameters: {
+ directory?: string
+ workspace?: string
+ sessionID: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "sessionID" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post(
+ {
+ url: "/deepagent/goal/resume",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ },
+ )
+ }
+
+ public goalStop(
+ parameters: {
+ directory?: string
+ workspace?: string
+ sessionID: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "sessionID" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/deepagent/goal/stop",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ public goalStatus(
+ parameters: {
+ directory?: string
+ workspace?: string
+ sessionID: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "query", key: "sessionID" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/deepagent/goal/status",
+ ...options,
+ ...params,
+ })
+ }
+
+ public goalStartable(
+ parameters: {
+ directory?: string
+ workspace?: string
+ sessionID: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "query", key: "sessionID" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get<
+ DeepagentGoalStartableResponses,
+ DeepagentGoalStartableErrors,
+ ThrowOnError
+ >({
+ url: "/deepagent/goal/startable",
+ ...options,
+ ...params,
+ })
+ }
+
+ public wikiPage(
+ parameters: {
+ directory?: string
+ workspace?: string
+ docId: string
+ scope: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "query", key: "docId" },
+ { in: "query", key: "scope" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/deepagent/wiki/page",
+ ...options,
+ ...params,
+ })
+ }
+
+ public wikiSearch(
+ parameters: {
+ directory?: string
+ workspace?: string
+ text: string
+ type?: string
+ scope?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "query", key: "text" },
+ { in: "query", key: "type" },
+ { in: "query", key: "scope" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/deepagent/wiki/search",
+ ...options,
+ ...params,
+ })
+ }
+
+ private _knowledge?: Knowledge
+ get knowledge(): Knowledge {
+ return (this._knowledge ??= new Knowledge({ client: this.client }))
+ }
+
+ private _envFacts?: EnvFacts
+ get envFacts(): EnvFacts {
+ return (this._envFacts ??= new EnvFacts({ client: this.client }))
+ }
+
+ private _panel?: Panel
+ get panel(): Panel {
+ return (this._panel ??= new Panel({ client: this.client }))
+ }
+
+ private _goal?: Goal
+ get goal(): Goal {
+ return (this._goal ??= new Goal({ client: this.client }))
+ }
+
+ private _wiki?: Wiki
+ get wiki(): Wiki {
+ return (this._wiki ??= new Wiki({ client: this.client }))
+ }
+}
+
+export class Approvals extends HeyApiClient {
+ /**
+ * Resolve an Approval Queue item
+ *
+ * V4.0 §D2: a human approves / rejects / acknowledges a pending item (first resolution wins).
+ */
+ public resolve(
+ parameters: {
+ directory?: string
+ workspace?: string
+ id: string
+ decision: "approved" | "rejected" | "acknowledged"
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "id" },
+ { in: "body", key: "decision" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post<
+ OversightApprovalsResolveResponses,
+ OversightApprovalsResolveErrors,
+ ThrowOnError
+ >({
+ url: "/oversight/approvals/resolve",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+}
+
+export class Oversight extends HeyApiClient {
+ /**
+ * Agent Dashboard metrics
+ *
+ * V4.0 §F1: DLQ total, push-rejected-by-reason, task success rate, conflict rate.
+ */
+ public metrics(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ from?: string
+ to?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "query", key: "from" },
+ { in: "query", key: "to" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/oversight/metrics",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Event trace
+ *
+ * V4.0 §F2: the causal event chain (event → route → agent → coordination) for a correlationID.
+ */
+ public trace(
+ parameters: {
+ directory?: string
+ workspace?: string
+ correlationID: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "query", key: "correlationID" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/oversight/trace",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Approval Queue (pending)
+ *
+ * V4.0 §D2: pending human-decision items (goal escalations, rollbacks, panel verdicts).
+ */
+ public approvals(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/oversight/approvals",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Record a human takeover
*
- * Project recent DeepAgent run control-plane artifacts into reviewer views.
+ * V4.0 §D2: record that a human stepped in over an agent (pause/revert/claim). Feeds §F human_takeover_total.
*/
- public reviews(
+ public takeover(
parameters?: {
directory?: string
workspace?: string
+ sessionID?: string
+ agentID?: string
+ reason?: string
},
options?: Options,
) {
@@ -2568,21 +3527,36 @@ export class Deepagent extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
+ { in: "body", key: "sessionID" },
+ { in: "body", key: "agentID" },
+ { in: "body", key: "reason" },
],
},
],
)
- return (options?.client ?? this.client).get({
- url: "/deepagent/reviews",
+ return (options?.client ?? this.client).post({
+ url: "/oversight/takeover",
...options,
...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
})
}
- public packsActive(
- parameters?: {
+ /**
+ * Roll back an agent-produced change
+ *
+ * V4.0 §D2: a human reverts a session's agent-produced changes via SessionRevert. Feeds §F rollback_total.
+ */
+ public rollback(
+ parameters: {
directory?: string
workspace?: string
+ sessionID: string
+ reason?: string
},
options?: Options,
) {
@@ -2593,25 +3567,48 @@ export class Deepagent extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
+ { in: "body", key: "sessionID" },
+ { in: "body", key: "reason" },
],
},
],
)
- return (options?.client ?? this.client).get<
- DeepagentPacksActiveResponses,
- DeepagentPacksActiveErrors,
- ThrowOnError
- >({
- url: "/deepagent/packs/active",
+ return (options?.client ?? this.client).post({
+ url: "/oversight/rollback",
...options,
...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
})
}
- public packsAll(
- parameters?: {
+ private _approvals?: Approvals
+ get approvals2(): Approvals {
+ return (this._approvals ??= new Approvals({ client: this.client }))
+ }
+}
+
+export class Webhook extends HeyApiClient {
+ /**
+ * Git push webhook
+ *
+ * V4.0 §A1: authenticate + publish a `git.push` DeepAgentEvent (CodeReviewAgent trigger). Opt-in trustedSources gates dispatch.
+ */
+ public git(
+ parameters: {
directory?: string
workspace?: string
+ repo: string
+ ref?: string
+ branch?: string
+ commit: string
+ actor?: string
+ deliveryId?: string
+ destructive?: boolean
+ message?: string
},
options?: Options,
) {
@@ -2622,22 +3619,49 @@ export class Deepagent extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
+ { in: "body", key: "repo" },
+ { in: "body", key: "ref" },
+ { in: "body", key: "branch" },
+ { in: "body", key: "commit" },
+ { in: "body", key: "actor" },
+ { in: "body", key: "deliveryId" },
+ { in: "body", key: "destructive" },
+ { in: "body", key: "message" },
],
},
],
)
- return (options?.client ?? this.client).get({
- url: "/deepagent/packs/all",
+ return (options?.client ?? this.client).post({
+ url: "/api/v1/webhook/git",
...options,
...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
})
}
- public packsPin(
+ /**
+ * CI failure webhook
+ *
+ * V4.0 §A1: authenticate + publish a `ci.failure` DeepAgentEvent (CodeFixAgent trigger). Opt-in trustedSources gates dispatch.
+ */
+ public ci(
parameters: {
directory?: string
workspace?: string
- packId: string
+ repo: string
+ ref?: string
+ branch?: string
+ commit?: string
+ actor?: string
+ deliveryId?: string
+ pipeline?: string
+ jobUrl?: string
+ consecutiveFailures?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ logExcerpt?: string
},
options?: Options,
) {
@@ -2648,13 +3672,22 @@ export class Deepagent extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
- { in: "body", key: "packId" },
+ { in: "body", key: "repo" },
+ { in: "body", key: "ref" },
+ { in: "body", key: "branch" },
+ { in: "body", key: "commit" },
+ { in: "body", key: "actor" },
+ { in: "body", key: "deliveryId" },
+ { in: "body", key: "pipeline" },
+ { in: "body", key: "jobUrl" },
+ { in: "body", key: "consecutiveFailures" },
+ { in: "body", key: "logExcerpt" },
],
},
],
)
- return (options?.client ?? this.client).post({
- url: "/deepagent/packs/pin",
+ return (options?.client ?? this.client).post({
+ url: "/api/v1/webhook/ci",
...options,
...params,
headers: {
@@ -2665,11 +3698,24 @@ export class Deepagent extends HeyApiClient {
})
}
- public packsUnpin(
+ /**
+ * PR comment webhook
+ *
+ * V4.0 §A1: authenticate + publish a `pr.comment` DeepAgentEvent (Review/Performance agent trigger). Opt-in trustedSources gates dispatch.
+ */
+ public pr(
parameters: {
directory?: string
workspace?: string
- packId: string
+ repo: string
+ prNumber?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ commit?: string
+ actor?: string
+ deliveryId?: string
+ comment: string
+ destructive?: boolean
+ migration?: boolean
+ architectureChange?: boolean
},
options?: Options,
) {
@@ -2680,33 +3726,78 @@ export class Deepagent extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
- { in: "body", key: "packId" },
+ { in: "body", key: "repo" },
+ { in: "body", key: "prNumber" },
+ { in: "body", key: "commit" },
+ { in: "body", key: "actor" },
+ { in: "body", key: "deliveryId" },
+ { in: "body", key: "comment" },
+ { in: "body", key: "destructive" },
+ { in: "body", key: "migration" },
+ { in: "body", key: "architectureChange" },
],
},
],
)
- return (options?.client ?? this.client).post(
- {
- url: "/deepagent/packs/unpin",
- ...options,
- ...params,
- headers: {
- "Content-Type": "application/json",
- ...options?.headers,
- ...params.headers,
- },
+ return (options?.client ?? this.client).post({
+ url: "/api/v1/webhook/pr",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
},
- )
- }
-
- private _knowledge?: Knowledge
- get knowledge(): Knowledge {
- return (this._knowledge ??= new Knowledge({ client: this.client }))
+ })
}
- private _envFacts?: EnvFacts
- get envFacts(): EnvFacts {
- return (this._envFacts ??= new EnvFacts({ client: this.client }))
+ /**
+ * Monitoring alert webhook
+ *
+ * V4.0 §A1: authenticate + publish a `monitor.alert` DeepAgentEvent (DiagnosisAgent trigger). Opt-in trustedSources gates dispatch.
+ */
+ public monitor(
+ parameters: {
+ directory?: string
+ workspace?: string
+ repo?: string
+ alertId?: string
+ deliveryId?: string
+ title: string
+ severity?: "info" | "warning" | "critical"
+ category?: string
+ detail?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "repo" },
+ { in: "body", key: "alertId" },
+ { in: "body", key: "deliveryId" },
+ { in: "body", key: "title" },
+ { in: "body", key: "severity" },
+ { in: "body", key: "category" },
+ { in: "body", key: "detail" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/api/v1/webhook/monitor",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
}
}
@@ -3762,8 +4853,12 @@ export class Groups extends HeyApiClient {
directory?: string
workspace?: string
name: string
- type: "project" | "system"
+ type: "project" | "system" | "direct"
projectID?: string
+ member?: {
+ memberID: string
+ memberType: "user" | "agent"
+ }
},
options?: Options,
) {
@@ -3777,6 +4872,7 @@ export class Groups extends HeyApiClient {
{ in: "body", key: "name" },
{ in: "body", key: "type" },
{ in: "body", key: "projectID" },
+ { in: "body", key: "member" },
],
},
],
@@ -3985,6 +5081,88 @@ export class Messages extends HeyApiClient {
...params,
})
}
+
+ /**
+ * List thread
+ *
+ * List the replies to a message (reply_to_id chain) with keyset pagination.
+ */
+ public thread(
+ parameters: {
+ groupId: string
+ messageId: string
+ directory?: string
+ workspace?: string
+ cursor?: string
+ limit?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "groupId" },
+ { in: "path", key: "messageId" },
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "query", key: "cursor" },
+ { in: "query", key: "limit" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/api/v1/im/groups/{groupId}/messages/{messageId}/thread",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Search messages
+ *
+ * Full-text search across messages in groups the caller belongs to, with optional group / sender / type / metadata filters and keyset pagination.
+ */
+ public search(
+ parameters: {
+ directory?: string
+ workspace?: string
+ q: string
+ groupId?: string
+ senderType?: "user" | "agent" | "system"
+ type?: "text" | "code" | "file" | "agent_status" | "system"
+ metadataType?: string
+ cursor?: string
+ limit?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "query", key: "q" },
+ { in: "query", key: "groupId" },
+ { in: "query", key: "senderType" },
+ { in: "query", key: "type" },
+ { in: "query", key: "metadataType" },
+ { in: "query", key: "cursor" },
+ { in: "query", key: "limit" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/api/v1/im/search",
+ ...options,
+ ...params,
+ })
+ }
}
export class Agents extends HeyApiClient {
@@ -4019,6 +5197,88 @@ export class Agents extends HeyApiClient {
}
}
+export class Attachments extends HeyApiClient {
+ /**
+ * List attachments
+ *
+ * List attachment records for a group, message, or the workspace.
+ */
+ public list(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ groupId?: string
+ messageId?: string
+ limit?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "query", key: "groupId" },
+ { in: "query", key: "messageId" },
+ { in: "query", key: "limit" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/api/v1/im/attachments",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Upload attachment
+ *
+ * Upload a file to local disk under the workspace data directory. Validates mime + size and computes a sha256 checksum. Gated on the v4FileUploadEnabled flag.
+ */
+ public upload(
+ parameters: {
+ directory?: string
+ workspace?: string
+ file: Blob | File
+ groupId?: string | null
+ messageId?: string | null
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "file" },
+ { in: "body", key: "groupId" },
+ { in: "body", key: "messageId" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post(
+ {
+ ...formDataBodySerializer,
+ url: "/api/v1/im/attachments",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": null,
+ ...options?.headers,
+ ...params.headers,
+ },
+ },
+ )
+ }
+}
+
export class Websocket extends HeyApiClient {
/**
* Connect to IM WebSocket
@@ -4069,6 +5329,11 @@ export class Im extends HeyApiClient {
return (this._agents ??= new Agents({ client: this.client }))
}
+ private _attachments?: Attachments
+ get attachments(): Attachments {
+ return (this._attachments ??= new Attachments({ client: this.client }))
+ }
+
private _websocket?: Websocket
get websocket(): Websocket {
return (this._websocket ??= new Websocket({ client: this.client }))
@@ -7706,7 +8971,7 @@ export class Session3 extends HeyApiClient {
sessionID: string
id?: string
prompt: Prompt
- delivery?: "steer" | "queue"
+ delivery?: "steer" | "queue" | "goal_steer"
resume?: boolean
},
options?: Options,
@@ -8297,6 +9562,16 @@ export class DeepAgentCodeClient extends HeyApiClient {
return (this._deepagent ??= new Deepagent({ client: this.client }))
}
+ private _oversight?: Oversight
+ get oversight(): Oversight {
+ return (this._oversight ??= new Oversight({ client: this.client }))
+ }
+
+ private _webhook?: Webhook
+ get webhook(): Webhook {
+ return (this._webhook ??= new Webhook({ client: this.client }))
+ }
+
private _tool?: Tool
get tool(): Tool {
return (this._tool ??= new Tool({ client: this.client }))
diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts
index 298afe4d..c1ebf4c0 100644
--- a/packages/sdk/js/src/gen/types.gen.ts
+++ b/packages/sdk/js/src/gen/types.gen.ts
@@ -91,6 +91,7 @@ export type Event =
| EventDebugOutput
| EventDebugTerminated
| EventDebugUpdated
+ | EventGoalUpdated
| EventVcsBranchUpdated
| EventWorkspaceReady
| EventWorkspaceFailed
@@ -853,7 +854,7 @@ export type GlobalEvent = {
sessionID: string
messageID: string
prompt: Prompt
- delivery: "steer" | "queue"
+ delivery: "steer" | "queue" | "goal_steer"
}
}
| {
@@ -864,7 +865,7 @@ export type GlobalEvent = {
sessionID: string
messageID: string
prompt: Prompt
- delivery: "steer" | "queue"
+ delivery: "steer" | "queue" | "goal_steer"
}
}
| {
@@ -1645,6 +1646,24 @@ export type GlobalEvent = {
status: string
}
}
+ | {
+ id: string
+ type: "goal.updated"
+ properties: {
+ sessionID: string
+ goalId: string
+ planDocId: string
+ phase: string
+ ledger: {
+ ticks: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ tokens: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ cost: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ wallclockMs: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ }
+ stallCount: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ gaps: Array
+ }
+ }
| {
id: string
type: "vcs.branch.updated"
@@ -1818,6 +1837,8 @@ export type AgentConfig = {
maxConcurrency?: number
maxTokensPerTurn?: number
maxTurnDurationMs?: number
+ maxFilesChanged?: number
+ maxTokensPerHour?: number
writablePaths?: Array
toolWhitelist?: Array
}
@@ -1832,6 +1853,7 @@ export type ProviderConfig = {
npm?: string
whitelist?: Array
blacklist?: Array
+ discovery?: boolean
options?: {
apiKey?: string
baseURL?: string
@@ -2723,6 +2745,35 @@ export type ImMessageNotFoundError = {
}
}
+export type ImThreadDisabledError = {
+ name: "THREAD_DISABLED"
+ data: {
+ message: string
+ }
+}
+
+export type ImFileUploadDisabledError = {
+ name: "FILE_UPLOAD_DISABLED"
+ data: {
+ message: string
+ }
+}
+
+export type ImFileTooLargeError = {
+ name: "FILE_TOO_LARGE"
+ data: {
+ message: string
+ maxBytes: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ }
+}
+
+export type ImUnsupportedMediaTypeError = {
+ name: "UNSUPPORTED_MEDIA_TYPE"
+ data: {
+ message: string
+ }
+}
+
export type Path = {
home: string
data: string
@@ -2834,6 +2885,8 @@ export type Agent = {
maxConcurrency?: number
maxTokensPerTurn?: number
maxTurnDurationMs?: number
+ maxFilesChanged?: number
+ maxTokensPerHour?: number
writablePaths?: Array
toolWhitelist?: Array
}
@@ -3745,7 +3798,7 @@ export type SyncEventSessionNextPrompted = {
sessionID: string
messageID: string
prompt: Prompt
- delivery: "steer" | "queue"
+ delivery: "steer" | "queue" | "goal_steer"
}
}
}
@@ -3763,7 +3816,7 @@ export type SyncEventSessionNextPromptAdmitted = {
sessionID: string
messageID: string
prompt: Prompt
- delivery: "steer" | "queue"
+ delivery: "steer" | "queue" | "goal_steer"
}
}
}
@@ -4307,7 +4360,7 @@ export type SessionInputAdmitted = {
id: string
sessionID: string
prompt: Prompt
- delivery: "steer" | "queue"
+ delivery: "steer" | "queue" | "goal_steer"
timeCreated: number
promotedSeq?: number
}
@@ -4897,7 +4950,7 @@ export type EventSessionNextPrompted = {
sessionID: string
messageID: string
prompt: Prompt
- delivery: "steer" | "queue"
+ delivery: "steer" | "queue" | "goal_steer"
}
}
@@ -4909,7 +4962,7 @@ export type EventSessionNextPromptAdmitted = {
sessionID: string
messageID: string
prompt: Prompt
- delivery: "steer" | "queue"
+ delivery: "steer" | "queue" | "goal_steer"
}
}
@@ -5707,6 +5760,25 @@ export type EventDebugUpdated = {
}
}
+export type EventGoalUpdated = {
+ id: string
+ type: "goal.updated"
+ properties: {
+ sessionID: string
+ goalId: string
+ planDocId: string
+ phase: string
+ ledger: {
+ ticks: number | "NaN" | "Infinity" | "-Infinity"
+ tokens: number | "NaN" | "Infinity" | "-Infinity"
+ cost: number | "NaN" | "Infinity" | "-Infinity"
+ wallclockMs: number | "NaN" | "Infinity" | "-Infinity"
+ }
+ stallCount: number | "NaN" | "Infinity" | "-Infinity"
+ gaps: Array
+ }
+}
+
export type EventVcsBranchUpdated = {
id: string
type: "vcs.branch.updated"
@@ -5949,6 +6021,14 @@ export type GlobalCapabilitiesResponses = {
sessions: boolean
pty: boolean
workspaces: boolean
+ expertPanel: boolean
+ goalLoop: boolean
+ wiki: boolean
+ v4EventDrivenIm?: boolean
+ v4AgentPushEnabled?: boolean
+ v4MultiAgentRuntime?: boolean
+ v4ThreadEnabled?: boolean
+ v4FileUploadEnabled?: boolean
}
}
}
@@ -6886,6 +6966,7 @@ export type DeepagentKnowledgePendingResponses = {
evidence_strength: "strong" | "medium" | "weak" | "none"
evidence_refs: Array
approval_status: "pending" | "approved" | "rejected"
+ scope?: string
}>
}
}
@@ -7291,296 +7372,1365 @@ export type DeepagentEnvFactsModifyResponses = {
export type DeepagentEnvFactsModifyResponse = DeepagentEnvFactsModifyResponses[keyof DeepagentEnvFactsModifyResponses]
-export type ExperimentalConsoleGetData = {
- body?: never
+export type DeepagentPanelConsultData = {
+ body?: {
+ sessionID: string
+ question?: string
+ codeRefs?: Array
+ lenses?: Array<"correctness" | "security" | "performance" | "architecture" | "repro">
+ maxRounds?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ policy?: "default" | "security"
+ }
path?: never
query?: {
directory?: string
workspace?: string
}
- url: "/experimental/console"
+ url: "/deepagent/panel/consult"
}
-export type ExperimentalConsoleGetErrors = {
- /**
- * Bad request
- */
- 400: BadRequestError
+export type DeepagentPanelConsultErrors = {
/**
- * InternalServerError
+ * DeepAgentPromotionError | InvalidRequestError
*/
- 500: EffectHttpApiErrorInternalServerError
+ 400: DeepAgentPromotionError | InvalidRequestError
}
-export type ExperimentalConsoleGetError = ExperimentalConsoleGetErrors[keyof ExperimentalConsoleGetErrors]
+export type DeepagentPanelConsultError = DeepagentPanelConsultErrors[keyof DeepagentPanelConsultErrors]
-export type ExperimentalConsoleGetResponses = {
+export type DeepagentPanelConsultResponses = {
/**
- * Active Console provider metadata
+ * Expert Panel verdict for the convened question
*/
- 200: ConsoleState
+ 200: {
+ decision: "approve" | "revise" | "block" | "needs_human"
+ confidence: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ rounds: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ evidence: Array
+ dissent: Array<{
+ lens: string
+ verdict: string
+ confidence: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ findings: Array<{
+ severity: string
+ category: string
+ file?: string
+ line?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ summary: string
+ failureScenario: string
+ confidence: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ }>
+ }>
+ }
}
-export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses]
+export type DeepagentPanelConsultResponse = DeepagentPanelConsultResponses[keyof DeepagentPanelConsultResponses]
-export type ExperimentalConsoleListOrgsData = {
- body?: never
+export type DeepagentPanelArmData = {
+ body?: {
+ sessionID: string
+ armed: boolean
+ rounds?: "single" | "multi"
+ }
path?: never
query?: {
directory?: string
workspace?: string
}
- url: "/experimental/console/orgs"
+ url: "/deepagent/panel/arm"
}
-export type ExperimentalConsoleListOrgsErrors = {
- /**
- * Bad request
- */
- 400: BadRequestError
+export type DeepagentPanelArmErrors = {
/**
- * InternalServerError
+ * DeepAgentPromotionError | InvalidRequestError
*/
- 500: EffectHttpApiErrorInternalServerError
+ 400: DeepAgentPromotionError | InvalidRequestError
}
-export type ExperimentalConsoleListOrgsError =
- ExperimentalConsoleListOrgsErrors[keyof ExperimentalConsoleListOrgsErrors]
+export type DeepagentPanelArmError = DeepagentPanelArmErrors[keyof DeepagentPanelArmErrors]
-export type ExperimentalConsoleListOrgsResponses = {
+export type DeepagentPanelArmResponses = {
/**
- * Switchable Console orgs
+ * The session's new panel armed state
*/
200: {
- orgs: Array<{
- accountID: string
- accountEmail: string
- accountUrl: string
- orgID: string
- orgName: string
- active: boolean
- }>
+ sessionID: string
+ armed: boolean
+ rounds: "single" | "multi"
}
}
-export type ExperimentalConsoleListOrgsResponse =
- ExperimentalConsoleListOrgsResponses[keyof ExperimentalConsoleListOrgsResponses]
+export type DeepagentPanelArmResponse = DeepagentPanelArmResponses[keyof DeepagentPanelArmResponses]
-export type ExperimentalConsoleSwitchOrgData = {
- body?: {
- accountID: string
- orgID: string
- }
+export type DeepagentPanelStatusData = {
+ body?: never
path?: never
- query?: {
+ query: {
directory?: string
workspace?: string
+ sessionID: string
}
- url: "/experimental/console/switch"
+ url: "/deepagent/panel/status"
}
-export type ExperimentalConsoleSwitchOrgResponses = {
+export type DeepagentPanelStatusErrors = {
/**
- * Switch success
+ * DeepAgentPromotionError | InvalidRequestError
*/
- 200: boolean
+ 400: DeepAgentPromotionError | InvalidRequestError
}
-export type ExperimentalConsoleSwitchOrgResponse =
- ExperimentalConsoleSwitchOrgResponses[keyof ExperimentalConsoleSwitchOrgResponses]
+export type DeepagentPanelStatusError = DeepagentPanelStatusErrors[keyof DeepagentPanelStatusErrors]
-export type ToolListData = {
- body?: never
+export type DeepagentPanelStatusResponses = {
+ /**
+ * Effective panel armed state (explicit toggle or global default)
+ */
+ 200: {
+ sessionID: string
+ armed: boolean
+ explicit: boolean
+ rounds: "single" | "multi"
+ }
+}
+
+export type DeepagentPanelStatusResponse = DeepagentPanelStatusResponses[keyof DeepagentPanelStatusResponses]
+
+export type DeepagentGoalStartData = {
+ body?: {
+ sessionID: string
+ objective?: string
+ criteria?: Array<{
+ kind: "tests_pass" | "no_diagnostics" | "reviewer_clean" | "panel_approves" | "plan_complete"
+ commands?: Array
+ maxSeverity?: string
+ severityAtMost?: string
+ }>
+ limits?: {
+ maxTicks?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ maxTokens?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ maxWallclockMs?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ maxCost?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ }
+ stallThreshold?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ }
path?: never
- query: {
+ query?: {
directory?: string
workspace?: string
- provider: string
- model: string
}
- url: "/experimental/tool"
+ url: "/deepagent/goal/start"
}
-export type ToolListErrors = {
+export type DeepagentGoalStartErrors = {
/**
- * BadRequest | InvalidRequestError
+ * DeepAgentPromotionError | InvalidRequestError
*/
- 400: EffectHttpApiErrorBadRequest | InvalidRequestError
+ 400: DeepAgentPromotionError | InvalidRequestError
}
-export type ToolListError = ToolListErrors[keyof ToolListErrors]
+export type DeepagentGoalStartError = DeepagentGoalStartErrors[keyof DeepagentGoalStartErrors]
-export type ToolListResponses = {
+export type DeepagentGoalStartResponses = {
/**
- * Tools
+ * The started goal (goalId + initial phase)
*/
- 200: ToolList
+ 200: {
+ goalId: string
+ planDocId: string
+ phase: string
+ running: boolean
+ }
}
-export type ToolListResponse = ToolListResponses[keyof ToolListResponses]
+export type DeepagentGoalStartResponse = DeepagentGoalStartResponses[keyof DeepagentGoalStartResponses]
-export type ToolIdsData = {
- body?: never
+export type DeepagentGoalPauseData = {
+ body?: {
+ sessionID: string
+ }
path?: never
query?: {
directory?: string
workspace?: string
}
- url: "/experimental/tool/ids"
+ url: "/deepagent/goal/pause"
}
-export type ToolIdsErrors = {
+export type DeepagentGoalPauseErrors = {
/**
- * BadRequest | InvalidRequestError
+ * DeepAgentPromotionError | InvalidRequestError
*/
- 400: EffectHttpApiErrorBadRequest | InvalidRequestError
+ 400: DeepAgentPromotionError | InvalidRequestError
}
-export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors]
+export type DeepagentGoalPauseError = DeepagentGoalPauseErrors[keyof DeepagentGoalPauseErrors]
-export type ToolIdsResponses = {
+export type DeepagentGoalPauseResponses = {
/**
- * Tool IDs
+ * Whether the goal was paused
*/
- 200: ToolIds
+ 200: {
+ ok: boolean
+ }
}
-export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses]
+export type DeepagentGoalPauseResponse = DeepagentGoalPauseResponses[keyof DeepagentGoalPauseResponses]
-export type WorktreeRemoveData = {
- body?: WorktreeRemoveInput
+export type DeepagentGoalResumeData = {
+ body?: {
+ sessionID: string
+ }
path?: never
query?: {
directory?: string
workspace?: string
}
- url: "/experimental/worktree"
+ url: "/deepagent/goal/resume"
}
-export type WorktreeRemoveErrors = {
+export type DeepagentGoalResumeErrors = {
/**
- * WorktreeError | InvalidRequestError
+ * DeepAgentPromotionError | InvalidRequestError
*/
- 400: WorktreeError | InvalidRequestError
+ 400: DeepAgentPromotionError | InvalidRequestError
}
-export type WorktreeRemoveError = WorktreeRemoveErrors[keyof WorktreeRemoveErrors]
+export type DeepagentGoalResumeError = DeepagentGoalResumeErrors[keyof DeepagentGoalResumeErrors]
-export type WorktreeRemoveResponses = {
+export type DeepagentGoalResumeResponses = {
/**
- * Worktree removed
+ * Whether the goal was resumed
*/
- 200: boolean
+ 200: {
+ ok: boolean
+ }
}
-export type WorktreeRemoveResponse = WorktreeRemoveResponses[keyof WorktreeRemoveResponses]
+export type DeepagentGoalResumeResponse = DeepagentGoalResumeResponses[keyof DeepagentGoalResumeResponses]
-export type WorktreeListData = {
- body?: never
+export type DeepagentGoalStopData = {
+ body?: {
+ sessionID: string
+ }
path?: never
query?: {
directory?: string
workspace?: string
}
- url: "/experimental/worktree"
+ url: "/deepagent/goal/stop"
}
-export type WorktreeListErrors = {
+export type DeepagentGoalStopErrors = {
/**
- * WorktreeError | InvalidRequestError
+ * DeepAgentPromotionError | InvalidRequestError
*/
- 400: WorktreeError | InvalidRequestError
+ 400: DeepAgentPromotionError | InvalidRequestError
}
-export type WorktreeListError = WorktreeListErrors[keyof WorktreeListErrors]
+export type DeepagentGoalStopError = DeepagentGoalStopErrors[keyof DeepagentGoalStopErrors]
-export type WorktreeListResponses = {
+export type DeepagentGoalStopResponses = {
/**
- * List of worktree directories
+ * Whether the goal was stopped
*/
- 200: Array
+ 200: {
+ ok: boolean
+ }
}
-export type WorktreeListResponse = WorktreeListResponses[keyof WorktreeListResponses]
+export type DeepagentGoalStopResponse = DeepagentGoalStopResponses[keyof DeepagentGoalStopResponses]
-export type WorktreeCreateData = {
- body?: WorktreeCreateInput
+export type DeepagentGoalEditPlanData = {
+ body?: {
+ sessionID: string
+ plan: {
+ goal: string
+ steps: Array<{
+ step_id?: string
+ title: string
+ status?: string
+ acceptance?: string
+ assigned_agent?: string
+ note?: string
+ }>
+ assumptions?: Array
+ active_step_id?: string
+ }
+ }
path?: never
query?: {
directory?: string
workspace?: string
}
- url: "/experimental/worktree"
+ url: "/deepagent/goal/edit-plan"
}
-export type WorktreeCreateErrors = {
+export type DeepagentGoalEditPlanErrors = {
/**
- * WorktreeError | InvalidRequestError
+ * DeepAgentPromotionError | InvalidRequestError
*/
- 400: WorktreeError | InvalidRequestError
+ 400: DeepAgentPromotionError | InvalidRequestError
}
-export type WorktreeCreateError = WorktreeCreateErrors[keyof WorktreeCreateErrors]
+export type DeepagentGoalEditPlanError = DeepagentGoalEditPlanErrors[keyof DeepagentGoalEditPlanErrors]
-export type WorktreeCreateResponses = {
+export type DeepagentGoalEditPlanResponses = {
/**
- * Worktree created
+ * Whether the plan edit was enqueued for the goal
*/
- 200: Worktree
+ 200: {
+ ok: boolean
+ }
}
-export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses]
+export type DeepagentGoalEditPlanResponse = DeepagentGoalEditPlanResponses[keyof DeepagentGoalEditPlanResponses]
-export type WorktreeResetData = {
- body?: WorktreeResetInput
+export type DeepagentGoalStatusData = {
+ body?: never
path?: never
- query?: {
+ query: {
directory?: string
workspace?: string
+ sessionID: string
}
- url: "/experimental/worktree/reset"
+ url: "/deepagent/goal/status"
}
-export type WorktreeResetErrors = {
+export type DeepagentGoalStatusErrors = {
/**
- * WorktreeError | InvalidRequestError
+ * DeepAgentPromotionError | InvalidRequestError
*/
- 400: WorktreeError | InvalidRequestError
+ 400: DeepAgentPromotionError | InvalidRequestError
}
-export type WorktreeResetError = WorktreeResetErrors[keyof WorktreeResetErrors]
+export type DeepagentGoalStatusError = DeepagentGoalStatusErrors[keyof DeepagentGoalStatusErrors]
-export type WorktreeResetResponses = {
+export type DeepagentGoalStatusResponses = {
/**
- * Worktree reset
+ * The active goal for the session, or null
*/
- 200: boolean
+ 200: {
+ goal: {
+ goalId: string
+ planDocId: string
+ phase: string
+ running: boolean
+ }
+ }
}
-export type WorktreeResetResponse = WorktreeResetResponses[keyof WorktreeResetResponses]
+export type DeepagentGoalStatusResponse = DeepagentGoalStatusResponses[keyof DeepagentGoalStatusResponses]
-export type WorktreeChangesData = {
- body?: WorktreeRemoveInput
+export type DeepagentGoalStartableData = {
+ body?: never
path?: never
- query?: {
+ query: {
directory?: string
workspace?: string
+ sessionID: string
}
- url: "/experimental/worktree/changes"
+ url: "/deepagent/goal/startable"
}
-export type WorktreeChangesErrors = {
+export type DeepagentGoalStartableErrors = {
/**
- * WorktreeError | InvalidRequestError
+ * DeepAgentPromotionError | InvalidRequestError
*/
- 400: WorktreeError | InvalidRequestError
+ 400: DeepAgentPromotionError | InvalidRequestError
}
-export type WorktreeChangesError = WorktreeChangesErrors[keyof WorktreeChangesErrors]
+export type DeepagentGoalStartableError = DeepagentGoalStartableErrors[keyof DeepagentGoalStartableErrors]
-export type WorktreeChangesResponses = {
+export type DeepagentGoalStartableResponses = {
/**
- * Worktree change count
+ * Whether a goal can be started + plan source
+ */
+ 200: {
+ startable: boolean
+ source: "plan" | "file" | "none"
+ }
+}
+
+export type DeepagentGoalStartableResponse = DeepagentGoalStartableResponses[keyof DeepagentGoalStartableResponses]
+
+export type DeepagentWikiPagesData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ type?: string
+ }
+ url: "/deepagent/wiki/pages"
+}
+
+export type DeepagentWikiPagesErrors = {
+ /**
+ * DeepAgentPromotionError | InvalidRequestError
+ */
+ 400: DeepAgentPromotionError | InvalidRequestError
+}
+
+export type DeepagentWikiPagesError = DeepagentWikiPagesErrors[keyof DeepagentWikiPagesErrors]
+
+export type DeepagentWikiPagesResponses = {
+ /**
+ * Projectable Wiki page summaries (sealed excluded)
+ */
+ 200: {
+ pages: Array<{
+ docId: string
+ type: string
+ title: string
+ scope: string
+ editable: boolean
+ version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ }>
+ }
+}
+
+export type DeepagentWikiPagesResponse = DeepagentWikiPagesResponses[keyof DeepagentWikiPagesResponses]
+
+export type DeepagentWikiPageData = {
+ body?: never
+ path?: never
+ query: {
+ directory?: string
+ workspace?: string
+ docId: string
+ scope: string
+ }
+ url: "/deepagent/wiki/page"
+}
+
+export type DeepagentWikiPageErrors = {
+ /**
+ * DeepAgentPromotionError | InvalidRequestError
+ */
+ 400: DeepAgentPromotionError | InvalidRequestError
+}
+
+export type DeepagentWikiPageError = DeepagentWikiPageErrors[keyof DeepagentWikiPageErrors]
+
+export type DeepagentWikiPageResponses = {
+ /**
+ * One rendered Wiki page (markdown + cross-links)
+ */
+ 200: {
+ docId: string
+ type: string
+ title: string
+ markdown: string
+ editable: boolean
+ version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ crossLinks: {
+ toCode: Array<{
+ docId: string
+ rel: string
+ path: string
+ line: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ symbolPath: string
+ stale: boolean
+ }>
+ toDocs: Array<{
+ docId: string
+ rel: string
+ type: string
+ title: string
+ stale: boolean
+ }>
+ }
+ }
+}
+
+export type DeepagentWikiPageResponse = DeepagentWikiPageResponses[keyof DeepagentWikiPageResponses]
+
+export type DeepagentWikiSearchData = {
+ body?: never
+ path?: never
+ query: {
+ directory?: string
+ workspace?: string
+ text: string
+ type?: string
+ scope?: string
+ }
+ url: "/deepagent/wiki/search"
+}
+
+export type DeepagentWikiSearchErrors = {
+ /**
+ * DeepAgentPromotionError | InvalidRequestError
+ */
+ 400: DeepAgentPromotionError | InvalidRequestError
+}
+
+export type DeepagentWikiSearchError = DeepagentWikiSearchErrors[keyof DeepagentWikiSearchErrors]
+
+export type DeepagentWikiSearchResponses = {
+ /**
+ * Full-text search hits over the Wiki projection
+ */
+ 200: {
+ hits: Array<{
+ docId: string
+ type: string
+ scope: string
+ title: string
+ score: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ }>
+ }
+}
+
+export type DeepagentWikiSearchResponse = DeepagentWikiSearchResponses[keyof DeepagentWikiSearchResponses]
+
+export type DeepagentWikiEditData = {
+ body?: {
+ docId: string
+ scope: string
+ body: string
+ editor: {
+ id: string
+ name?: string
+ }
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/deepagent/wiki/edit"
+}
+
+export type DeepagentWikiEditErrors = {
+ /**
+ * DeepAgentPromotionError | InvalidRequestError
+ */
+ 400: DeepAgentPromotionError | InvalidRequestError
+}
+
+export type DeepagentWikiEditError = DeepagentWikiEditErrors[keyof DeepagentWikiEditErrors]
+
+export type DeepagentWikiEditResponses = {
+ /**
+ * The edited page (new version, human provenance)
+ */
+ 200: {
+ docId: string
+ type: string
+ title: string
+ markdown: string
+ editable: boolean
+ version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ crossLinks: {
+ toCode: Array<{
+ docId: string
+ rel: string
+ path: string
+ line: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ symbolPath: string
+ stale: boolean
+ }>
+ toDocs: Array<{
+ docId: string
+ rel: string
+ type: string
+ title: string
+ stale: boolean
+ }>
+ }
+ }
+}
+
+export type DeepagentWikiEditResponse = DeepagentWikiEditResponses[keyof DeepagentWikiEditResponses]
+
+export type DeepagentWikiExecutionArchiveData = {
+ body?: never
+ path?: never
+ query: {
+ directory?: string
+ workspace?: string
+ sessionID: string
+ }
+ url: "/deepagent/wiki/execution-archive"
+}
+
+export type DeepagentWikiExecutionArchiveErrors = {
+ /**
+ * DeepAgentPromotionError | InvalidRequestError
+ */
+ 400: DeepAgentPromotionError | InvalidRequestError
+}
+
+export type DeepagentWikiExecutionArchiveError =
+ DeepagentWikiExecutionArchiveErrors[keyof DeepagentWikiExecutionArchiveErrors]
+
+export type DeepagentWikiExecutionArchiveResponses = {
+ /**
+ * A session's aggregated execution trajectory (plan + worklog + diagnosis + decision + eval)
+ */
+ 200: {
+ sessionId: string
+ title: string
+ markdown: string
+ entries: Array<{
+ docId: string
+ type: string
+ title: string
+ body: string
+ version: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ }>
+ }
+}
+
+export type DeepagentWikiExecutionArchiveResponse =
+ DeepagentWikiExecutionArchiveResponses[keyof DeepagentWikiExecutionArchiveResponses]
+
+export type OversightMetricsData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ from?: string
+ to?: string
+ }
+ url: "/oversight/metrics"
+}
+
+export type OversightMetricsErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type OversightMetricsError = OversightMetricsErrors[keyof OversightMetricsErrors]
+
+export type OversightMetricsResponses = {
+ /**
+ * §F1 metric snapshot for the workspace over the window
+ */
+ 200: {
+ windowFrom: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ windowTo: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ dlqEventsTotal: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ agentPushRejectedTotal: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ agentPushRejectedByReason: {
+ [key: string]: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ }
+ agentTaskSuccessRate: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ agentTaskCompleted: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ agentTaskFailed: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ agentConflictRate: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ agentTaskBlockedTotal: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ agentPushTotal: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ eventPublishLatencyMsP50?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ eventPublishLatencyMsP95?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ eventToAgentStartMsP50?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ eventToAgentStartMsP95?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ humanTakeoverTotal?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ rollbackTotal?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ }
+}
+
+export type OversightMetricsResponse = OversightMetricsResponses[keyof OversightMetricsResponses]
+
+export type OversightTraceData = {
+ body?: never
+ path?: never
+ query: {
+ directory?: string
+ workspace?: string
+ correlationID: string
+ }
+ url: "/oversight/trace"
+}
+
+export type OversightTraceErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type OversightTraceError = OversightTraceErrors[keyof OversightTraceErrors]
+
+export type OversightTraceResponses = {
+ /**
+ * §F2 causal event chain for a correlationID
+ */
+ 200: {
+ nodes: Array<{
+ kind?: "event" | "session"
+ eventID: string
+ type: string
+ source: string
+ causationID?: string
+ createdAt: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ sessionID?: string
+ title?: string
+ messageCount?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ }>
+ }
+}
+
+export type OversightTraceResponse = OversightTraceResponses[keyof OversightTraceResponses]
+
+export type OversightApprovalsData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/oversight/approvals"
+}
+
+export type OversightApprovalsErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type OversightApprovalsError = OversightApprovalsErrors[keyof OversightApprovalsErrors]
+
+export type OversightApprovalsResponses = {
+ /**
+ * §D2 pending Approval Queue items for the workspace
+ */
+ 200: {
+ items: Array<{
+ id: string
+ workspaceID: string
+ eventID: string
+ eventType: string
+ correlationID?: string
+ summary: string
+ status: "pending" | "resolved"
+ decision?: "approved" | "rejected" | "acknowledged"
+ resolvedBy?: string
+ resolvedAt?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ createdAt: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ }>
+ }
+}
+
+export type OversightApprovalsResponse = OversightApprovalsResponses[keyof OversightApprovalsResponses]
+
+export type OversightApprovalsResolveData = {
+ body?: {
+ id: string
+ decision: "approved" | "rejected" | "acknowledged"
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/oversight/approvals/resolve"
+}
+
+export type OversightApprovalsResolveErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+ /**
+ * Not found
+ */
+ 404: NotFoundError
+}
+
+export type OversightApprovalsResolveError = OversightApprovalsResolveErrors[keyof OversightApprovalsResolveErrors]
+
+export type OversightApprovalsResolveResponses = {
+ /**
+ * The resolved Approval Queue item
+ */
+ 200: {
+ id: string
+ workspaceID: string
+ eventID: string
+ eventType: string
+ correlationID?: string
+ summary: string
+ status: "pending" | "resolved"
+ decision?: "approved" | "rejected" | "acknowledged"
+ resolvedBy?: string
+ resolvedAt?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ createdAt: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ }
+}
+
+export type OversightApprovalsResolveResponse =
+ OversightApprovalsResolveResponses[keyof OversightApprovalsResolveResponses]
+
+export type OversightTakeoverData = {
+ body?: {
+ sessionID?: string
+ agentID?: string
+ reason?: string
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/oversight/takeover"
+}
+
+export type OversightTakeoverErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type OversightTakeoverError = OversightTakeoverErrors[keyof OversightTakeoverErrors]
+
+export type OversightTakeoverResponses = {
+ /**
+ * The recorded human-takeover audit row
+ */
+ 200: {
+ id: string
+ workspaceID: string
+ sessionID?: string
+ agentID?: string
+ actorID?: string
+ reason?: string
+ createdAt: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ }
+}
+
+export type OversightTakeoverResponse = OversightTakeoverResponses[keyof OversightTakeoverResponses]
+
+export type OversightRollbackData = {
+ body?: {
+ sessionID: string
+ reason?: string
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/oversight/rollback"
+}
+
+export type OversightRollbackErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+ /**
+ * Not found
+ */
+ 404: NotFoundError
+}
+
+export type OversightRollbackError = OversightRollbackErrors[keyof OversightRollbackErrors]
+
+export type OversightRollbackResponses = {
+ /**
+ * The recorded rollback audit row
+ */
+ 200: {
+ id: string
+ workspaceID: string
+ sessionID: string
+ actorID?: string
+ reason?: string
+ outcome: "reverted" | "noop"
+ createdAt: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ }
+}
+
+export type OversightRollbackResponse = OversightRollbackResponses[keyof OversightRollbackResponses]
+
+export type WebhookGitData = {
+ body: {
+ repo: string
+ ref?: string
+ branch?: string
+ commit: string
+ actor?: string
+ deliveryId?: string
+ destructive?: boolean
+ message?: string
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/api/v1/webhook/git"
+}
+
+export type WebhookGitErrors = {
+ /**
+ * InvalidRequestError
+ */
+ 400: InvalidRequestError
+ /**
+ * Unauthorized
+ */
+ 401: unknown
+}
+
+export type WebhookGitError = WebhookGitErrors[keyof WebhookGitErrors]
+
+export type WebhookGitResponses = {
+ /**
+ * §A1 git.push published onto the bus (or shed by the §E2 rate gate)
+ */
+ 200: {
+ accepted: boolean
+ dropped: boolean
+ eventID?: string
+ idempotencyKey?: string
+ type: string
+ }
+}
+
+export type WebhookGitResponse = WebhookGitResponses[keyof WebhookGitResponses]
+
+export type WebhookCiData = {
+ body: {
+ repo: string
+ ref?: string
+ branch?: string
+ commit?: string
+ actor?: string
+ deliveryId?: string
+ pipeline?: string
+ jobUrl?: string
+ consecutiveFailures?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ logExcerpt?: string
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/api/v1/webhook/ci"
+}
+
+export type WebhookCiErrors = {
+ /**
+ * InvalidRequestError
+ */
+ 400: InvalidRequestError
+ /**
+ * Unauthorized
+ */
+ 401: unknown
+}
+
+export type WebhookCiError = WebhookCiErrors[keyof WebhookCiErrors]
+
+export type WebhookCiResponses = {
+ /**
+ * §A1 ci.failure published onto the bus (or shed by the §E2 rate gate)
+ */
+ 200: {
+ accepted: boolean
+ dropped: boolean
+ eventID?: string
+ idempotencyKey?: string
+ type: string
+ }
+}
+
+export type WebhookCiResponse = WebhookCiResponses[keyof WebhookCiResponses]
+
+export type WebhookPrData = {
+ body: {
+ repo: string
+ prNumber?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ commit?: string
+ actor?: string
+ deliveryId?: string
+ comment: string
+ destructive?: boolean
+ migration?: boolean
+ architectureChange?: boolean
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/api/v1/webhook/pr"
+}
+
+export type WebhookPrErrors = {
+ /**
+ * InvalidRequestError
+ */
+ 400: InvalidRequestError
+ /**
+ * Unauthorized
+ */
+ 401: unknown
+}
+
+export type WebhookPrError = WebhookPrErrors[keyof WebhookPrErrors]
+
+export type WebhookPrResponses = {
+ /**
+ * §A1 pr.comment published onto the bus (or shed by the §E2 rate gate)
+ */
+ 200: {
+ accepted: boolean
+ dropped: boolean
+ eventID?: string
+ idempotencyKey?: string
+ type: string
+ }
+}
+
+export type WebhookPrResponse = WebhookPrResponses[keyof WebhookPrResponses]
+
+export type WebhookMonitorData = {
+ body: {
+ repo?: string
+ alertId?: string
+ deliveryId?: string
+ title: string
+ severity?: "info" | "warning" | "critical"
+ category?: string
+ detail?: string
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/api/v1/webhook/monitor"
+}
+
+export type WebhookMonitorErrors = {
+ /**
+ * InvalidRequestError
+ */
+ 400: InvalidRequestError
+ /**
+ * Unauthorized
+ */
+ 401: unknown
+}
+
+export type WebhookMonitorError = WebhookMonitorErrors[keyof WebhookMonitorErrors]
+
+export type WebhookMonitorResponses = {
+ /**
+ * §A1 monitor.alert published onto the bus (or shed by the §E2 rate gate)
+ */
+ 200: {
+ accepted: boolean
+ dropped: boolean
+ eventID?: string
+ idempotencyKey?: string
+ type: string
+ }
+}
+
+export type WebhookMonitorResponse = WebhookMonitorResponses[keyof WebhookMonitorResponses]
+
+export type ExperimentalConsoleGetData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/experimental/console"
+}
+
+export type ExperimentalConsoleGetErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+ /**
+ * InternalServerError
+ */
+ 500: EffectHttpApiErrorInternalServerError
+}
+
+export type ExperimentalConsoleGetError = ExperimentalConsoleGetErrors[keyof ExperimentalConsoleGetErrors]
+
+export type ExperimentalConsoleGetResponses = {
+ /**
+ * Active Console provider metadata
+ */
+ 200: ConsoleState
+}
+
+export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses]
+
+export type ExperimentalConsoleListOrgsData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/experimental/console/orgs"
+}
+
+export type ExperimentalConsoleListOrgsErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+ /**
+ * InternalServerError
+ */
+ 500: EffectHttpApiErrorInternalServerError
+}
+
+export type ExperimentalConsoleListOrgsError =
+ ExperimentalConsoleListOrgsErrors[keyof ExperimentalConsoleListOrgsErrors]
+
+export type ExperimentalConsoleListOrgsResponses = {
+ /**
+ * Switchable Console orgs
+ */
+ 200: {
+ orgs: Array<{
+ accountID: string
+ accountEmail: string
+ accountUrl: string
+ orgID: string
+ orgName: string
+ active: boolean
+ }>
+ }
+}
+
+export type ExperimentalConsoleListOrgsResponse =
+ ExperimentalConsoleListOrgsResponses[keyof ExperimentalConsoleListOrgsResponses]
+
+export type ExperimentalConsoleSwitchOrgData = {
+ body?: {
+ accountID: string
+ orgID: string
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/experimental/console/switch"
+}
+
+export type ExperimentalConsoleSwitchOrgResponses = {
+ /**
+ * Switch success
+ */
+ 200: boolean
+}
+
+export type ExperimentalConsoleSwitchOrgResponse =
+ ExperimentalConsoleSwitchOrgResponses[keyof ExperimentalConsoleSwitchOrgResponses]
+
+export type ToolListData = {
+ body?: never
+ path?: never
+ query: {
+ directory?: string
+ workspace?: string
+ provider: string
+ model: string
+ }
+ url: "/experimental/tool"
+}
+
+export type ToolListErrors = {
+ /**
+ * BadRequest | InvalidRequestError
+ */
+ 400: EffectHttpApiErrorBadRequest | InvalidRequestError
+}
+
+export type ToolListError = ToolListErrors[keyof ToolListErrors]
+
+export type ToolListResponses = {
+ /**
+ * Tools
+ */
+ 200: ToolList
+}
+
+export type ToolListResponse = ToolListResponses[keyof ToolListResponses]
+
+export type ToolIdsData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/experimental/tool/ids"
+}
+
+export type ToolIdsErrors = {
+ /**
+ * BadRequest | InvalidRequestError
+ */
+ 400: EffectHttpApiErrorBadRequest | InvalidRequestError
+}
+
+export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors]
+
+export type ToolIdsResponses = {
+ /**
+ * Tool IDs
+ */
+ 200: ToolIds
+}
+
+export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses]
+
+export type WorktreeRemoveData = {
+ body?: WorktreeRemoveInput
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/experimental/worktree"
+}
+
+export type WorktreeRemoveErrors = {
+ /**
+ * WorktreeError | InvalidRequestError
+ */
+ 400: WorktreeError | InvalidRequestError
+}
+
+export type WorktreeRemoveError = WorktreeRemoveErrors[keyof WorktreeRemoveErrors]
+
+export type WorktreeRemoveResponses = {
+ /**
+ * Worktree removed
+ */
+ 200: boolean
+}
+
+export type WorktreeRemoveResponse = WorktreeRemoveResponses[keyof WorktreeRemoveResponses]
+
+export type WorktreeListData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/experimental/worktree"
+}
+
+export type WorktreeListErrors = {
+ /**
+ * WorktreeError | InvalidRequestError
+ */
+ 400: WorktreeError | InvalidRequestError
+}
+
+export type WorktreeListError = WorktreeListErrors[keyof WorktreeListErrors]
+
+export type WorktreeListResponses = {
+ /**
+ * List of worktree directories
+ */
+ 200: Array
+}
+
+export type WorktreeListResponse = WorktreeListResponses[keyof WorktreeListResponses]
+
+export type WorktreeCreateData = {
+ body?: WorktreeCreateInput
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/experimental/worktree"
+}
+
+export type WorktreeCreateErrors = {
+ /**
+ * WorktreeError | InvalidRequestError
+ */
+ 400: WorktreeError | InvalidRequestError
+}
+
+export type WorktreeCreateError = WorktreeCreateErrors[keyof WorktreeCreateErrors]
+
+export type WorktreeCreateResponses = {
+ /**
+ * Worktree created
+ */
+ 200: Worktree
+}
+
+export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses]
+
+export type WorktreeResetData = {
+ body?: WorktreeResetInput
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/experimental/worktree/reset"
+}
+
+export type WorktreeResetErrors = {
+ /**
+ * WorktreeError | InvalidRequestError
+ */
+ 400: WorktreeError | InvalidRequestError
+}
+
+export type WorktreeResetError = WorktreeResetErrors[keyof WorktreeResetErrors]
+
+export type WorktreeResetResponses = {
+ /**
+ * Worktree reset
+ */
+ 200: boolean
+}
+
+export type WorktreeResetResponse = WorktreeResetResponses[keyof WorktreeResetResponses]
+
+export type WorktreeChangesData = {
+ body?: WorktreeRemoveInput
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/experimental/worktree/changes"
+}
+
+export type WorktreeChangesErrors = {
+ /**
+ * WorktreeError | InvalidRequestError
+ */
+ 400: WorktreeError | InvalidRequestError
+}
+
+export type WorktreeChangesError = WorktreeChangesErrors[keyof WorktreeChangesErrors]
+
+export type WorktreeChangesResponses = {
+ /**
+ * Worktree change count
*/
200: WorktreeChangeCount
}
@@ -8411,8 +9561,12 @@ export type ImGroupsListResponse = ImGroupsListResponses[keyof ImGroupsListRespo
export type ImGroupsCreateData = {
body: {
name: string
- type: "project" | "system"
+ type: "project" | "system" | "direct"
projectID?: string
+ member?: {
+ memberID: string
+ memberType: "user" | "agent"
+ }
}
path?: never
query?: {
@@ -8730,6 +9884,8 @@ export type ImAgentsListResponses = {
maxConcurrency?: number
maxTokensPerTurn?: number
maxTurnDurationMs?: number
+ maxFilesChanged?: number
+ maxTokensPerHour?: number
writablePaths?: Array
toolWhitelist?: Array
}
@@ -8796,6 +9952,249 @@ export type ImMessagesGetResponses = {
export type ImMessagesGetResponse = ImMessagesGetResponses[keyof ImMessagesGetResponses]
+export type ImMessagesThreadData = {
+ body?: never
+ path: {
+ groupId: string
+ messageId: string
+ }
+ query?: {
+ directory?: string
+ workspace?: string
+ cursor?: string
+ limit?: string
+ }
+ url: "/api/v1/im/groups/{groupId}/messages/{messageId}/thread"
+}
+
+export type ImMessagesThreadErrors = {
+ /**
+ * InvalidRequestError
+ */
+ 400: InvalidRequestError
+ /**
+ * Unauthorized
+ */
+ 401: unknown
+ /**
+ * IMPermissionDeniedError
+ */
+ 403: ImPermissionDeniedError
+ /**
+ * IMThreadDisabledError | IMGroupNotFoundError
+ */
+ 404: ImThreadDisabledError | ImGroupNotFoundError
+ /**
+ * IMInternalServerError
+ */
+ 500: ImInternalServerError
+}
+
+export type ImMessagesThreadError = ImMessagesThreadErrors[keyof ImMessagesThreadErrors]
+
+export type ImMessagesThreadResponses = {
+ /**
+ * Thread messages
+ */
+ 200: {
+ messages: Array<{
+ id: string
+ groupID: string
+ senderID: string
+ senderType: string
+ type: string
+ content: string
+ mentions: Array
+ metadata: unknown
+ replyToID: string
+ createdAt: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ updatedAt: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ }>
+ nextCursor: string
+ hasMore: boolean
+ }
+}
+
+export type ImMessagesThreadResponse = ImMessagesThreadResponses[keyof ImMessagesThreadResponses]
+
+export type ImMessagesSearchData = {
+ body?: never
+ path?: never
+ query: {
+ directory?: string
+ workspace?: string
+ q: string
+ groupId?: string
+ senderType?: "user" | "agent" | "system"
+ type?: "text" | "code" | "file" | "agent_status" | "system"
+ metadataType?: string
+ cursor?: string
+ limit?: string
+ }
+ url: "/api/v1/im/search"
+}
+
+export type ImMessagesSearchErrors = {
+ /**
+ * IMValidationFailedError | InvalidRequestError
+ */
+ 400: ImValidationFailedError | InvalidRequestError
+ /**
+ * Unauthorized
+ */
+ 401: unknown
+ /**
+ * IMInternalServerError
+ */
+ 500: ImInternalServerError
+}
+
+export type ImMessagesSearchError = ImMessagesSearchErrors[keyof ImMessagesSearchErrors]
+
+export type ImMessagesSearchResponses = {
+ /**
+ * Search results
+ */
+ 200: {
+ messages: Array<{
+ id: string
+ groupID: string
+ senderID: string
+ senderType: string
+ type: string
+ content: string
+ mentions: Array
+ metadata: unknown
+ replyToID: string
+ createdAt: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ updatedAt: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ }>
+ nextCursor: string
+ hasMore: boolean
+ }
+}
+
+export type ImMessagesSearchResponse = ImMessagesSearchResponses[keyof ImMessagesSearchResponses]
+
+export type ImAttachmentsListData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ groupId?: string
+ messageId?: string
+ limit?: string
+ }
+ url: "/api/v1/im/attachments"
+}
+
+export type ImAttachmentsListErrors = {
+ /**
+ * InvalidRequestError
+ */
+ 400: InvalidRequestError
+ /**
+ * Unauthorized
+ */
+ 401: unknown
+ /**
+ * IMFileUploadDisabledError | IMGroupNotFoundError
+ */
+ 404: ImFileUploadDisabledError | ImGroupNotFoundError
+ /**
+ * IMInternalServerError
+ */
+ 500: ImInternalServerError
+}
+
+export type ImAttachmentsListError = ImAttachmentsListErrors[keyof ImAttachmentsListErrors]
+
+export type ImAttachmentsListResponses = {
+ /**
+ * Attachments
+ */
+ 200: Array<{
+ id: string
+ workspaceID: string
+ projectID: string
+ groupID: string
+ messageID: string
+ uploadedBy: string
+ filename: string
+ mime: string
+ sizeBytes: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ checksum: string
+ createdAt: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ }>
+}
+
+export type ImAttachmentsListResponse = ImAttachmentsListResponses[keyof ImAttachmentsListResponses]
+
+export type ImAttachmentsUploadData = {
+ body: {
+ file: Blob | File
+ groupId?: string | null
+ messageId?: string | null
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/api/v1/im/attachments"
+}
+
+export type ImAttachmentsUploadErrors = {
+ /**
+ * IMValidationFailedError | BadRequest | InvalidRequestError
+ */
+ 400: ImValidationFailedError | EffectHttpApiErrorBadRequest | InvalidRequestError
+ /**
+ * Unauthorized
+ */
+ 401: unknown
+ /**
+ * IMFileUploadDisabledError | IMGroupNotFoundError
+ */
+ 404: ImFileUploadDisabledError | ImGroupNotFoundError
+ /**
+ * IMFileTooLargeError
+ */
+ 413: ImFileTooLargeError
+ /**
+ * IMUnsupportedMediaTypeError
+ */
+ 415: ImUnsupportedMediaTypeError
+ /**
+ * IMInternalServerError
+ */
+ 500: ImInternalServerError
+}
+
+export type ImAttachmentsUploadError = ImAttachmentsUploadErrors[keyof ImAttachmentsUploadErrors]
+
+export type ImAttachmentsUploadResponses = {
+ /**
+ * Uploaded attachment
+ */
+ 200: {
+ id: string
+ workspaceID: string
+ projectID: string
+ groupID: string
+ messageID: string
+ uploadedBy: string
+ filename: string
+ mime: string
+ sizeBytes: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ checksum: string
+ createdAt: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ }
+}
+
+export type ImAttachmentsUploadResponse = ImAttachmentsUploadResponses[keyof ImAttachmentsUploadResponses]
+
export type InstanceDisposeData = {
body?: never
path?: never
@@ -10290,6 +11689,7 @@ export type ProviderModelsDiscoverResponses = {
200: {
providerID: string
baseURL: string
+ kind: "openai-compatible" | "anthropic"
models: Array<{
id: string
name: string
@@ -12463,7 +13863,7 @@ export type V2SessionPromptData = {
body: {
id?: string
prompt: Prompt
- delivery?: "steer" | "queue"
+ delivery?: "steer" | "queue" | "goal_steer"
resume?: boolean
}
path: {
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index 94b79c44..ffd66cfa 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -1,7 +1,13 @@
// This file is auto-generated by @hey-api/openapi-ts
import { client } from "./client.gen.js"
-import { buildClientParams, type Client, type Options as Options2, type TDataShape } from "./client/index.js"
+import {
+ buildClientParams,
+ type Client,
+ formDataBodySerializer,
+ type Options as Options2,
+ type TDataShape,
+} from "./client/index.js"
import type {
AgentPartInput,
AppAgentsErrors,
@@ -58,6 +64,20 @@ import type {
DeepagentEnvFactsListResponses,
DeepagentEnvFactsModifyErrors,
DeepagentEnvFactsModifyResponses,
+ DeepagentGoalEditPlanErrors,
+ DeepagentGoalEditPlanResponses,
+ DeepagentGoalPauseErrors,
+ DeepagentGoalPauseResponses,
+ DeepagentGoalResumeErrors,
+ DeepagentGoalResumeResponses,
+ DeepagentGoalStartableErrors,
+ DeepagentGoalStartableResponses,
+ DeepagentGoalStartErrors,
+ DeepagentGoalStartResponses,
+ DeepagentGoalStatusErrors,
+ DeepagentGoalStatusResponses,
+ DeepagentGoalStopErrors,
+ DeepagentGoalStopResponses,
DeepagentKnowledgeApproveErrors,
DeepagentKnowledgeApproveResponses,
DeepagentKnowledgePendingErrors,
@@ -78,8 +98,24 @@ import type {
DeepagentPacksPinResponses,
DeepagentPacksUnpinErrors,
DeepagentPacksUnpinResponses,
+ DeepagentPanelArmErrors,
+ DeepagentPanelArmResponses,
+ DeepagentPanelConsultErrors,
+ DeepagentPanelConsultResponses,
+ DeepagentPanelStatusErrors,
+ DeepagentPanelStatusResponses,
DeepagentReviewsErrors,
DeepagentReviewsResponses,
+ DeepagentWikiEditErrors,
+ DeepagentWikiEditResponses,
+ DeepagentWikiExecutionArchiveErrors,
+ DeepagentWikiExecutionArchiveResponses,
+ DeepagentWikiPageErrors,
+ DeepagentWikiPageResponses,
+ DeepagentWikiPagesErrors,
+ DeepagentWikiPagesResponses,
+ DeepagentWikiSearchErrors,
+ DeepagentWikiSearchResponses,
EventSubscribeResponse,
EventSubscribeResponses,
EventTuiCommandExecute,
@@ -181,6 +217,10 @@ import type {
GlobalUpgradeResponses,
ImAgentsListErrors,
ImAgentsListResponses,
+ ImAttachmentsListErrors,
+ ImAttachmentsListResponses,
+ ImAttachmentsUploadErrors,
+ ImAttachmentsUploadResponses,
ImGroupsCreateErrors,
ImGroupsCreateResponses,
ImGroupsListErrors,
@@ -193,6 +233,10 @@ import type {
ImMessagesListResponses,
ImMessagesMarkReadErrors,
ImMessagesMarkReadResponses,
+ ImMessagesSearchErrors,
+ ImMessagesSearchResponses,
+ ImMessagesThreadErrors,
+ ImMessagesThreadResponses,
ImWebsocketConnectResponses,
InstanceDisposeErrors,
InstanceDisposeResponses,
@@ -240,6 +284,18 @@ import type {
McpStatusResponses,
MoveSessionDestination,
OutputFormat,
+ OversightApprovalsErrors,
+ OversightApprovalsResolveErrors,
+ OversightApprovalsResolveResponses,
+ OversightApprovalsResponses,
+ OversightMetricsErrors,
+ OversightMetricsResponses,
+ OversightRollbackErrors,
+ OversightRollbackResponses,
+ OversightTakeoverErrors,
+ OversightTakeoverResponses,
+ OversightTraceErrors,
+ OversightTraceResponses,
Part as Part2,
PartDeleteErrors,
PartDeleteResponses,
@@ -464,6 +520,14 @@ import type {
VcsGetResponses,
VcsStatusErrors,
VcsStatusResponses,
+ WebhookCiErrors,
+ WebhookCiResponses,
+ WebhookGitErrors,
+ WebhookGitResponses,
+ WebhookMonitorErrors,
+ WebhookMonitorResponses,
+ WebhookPrErrors,
+ WebhookPrResponses,
WorktreeChangesErrors,
WorktreeChangesResponses,
WorktreeCreateErrors,
@@ -2548,16 +2612,911 @@ export class EnvFacts extends HeyApiClient {
}
}
+export class Panel extends HeyApiClient {
+ /**
+ * Convene the Expert Panel (会诊)
+ *
+ * V3.9 §C: freeze the question, fan out the lens panelists (equal-footing debate), and return the deterministic arbiter verdict. Gated by the expert-panel flag.
+ */
+ public consult(
+ parameters: {
+ directory?: string
+ workspace?: string
+ sessionID: string
+ question?: string
+ codeRefs?: Array
+ lenses?: Array<"correctness" | "security" | "performance" | "architecture" | "repro">
+ maxRounds?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ policy?: "default" | "security"
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "sessionID" },
+ { in: "body", key: "question" },
+ { in: "body", key: "codeRefs" },
+ { in: "body", key: "lenses" },
+ { in: "body", key: "maxRounds" },
+ { in: "body", key: "policy" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post<
+ DeepagentPanelConsultResponses,
+ DeepagentPanelConsultErrors,
+ ThrowOnError
+ >({
+ url: "/deepagent/panel/consult",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * Arm or disarm the Expert Panel for a session
+ *
+ * V3.9 §C: per-conversation toggle for the panel button; seeded from the global default.
+ */
+ public arm(
+ parameters: {
+ directory?: string
+ workspace?: string
+ sessionID: string
+ armed: boolean
+ rounds?: "single" | "multi"
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "sessionID" },
+ { in: "body", key: "armed" },
+ { in: "body", key: "rounds" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/deepagent/panel/arm",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * Resolve the effective Expert Panel armed state
+ *
+ * V3.9 §C: returns the explicit per-session toggle if set, else the global expertPanelDefault — so the UI seeds the button from the server's default without guessing.
+ */
+ public status(
+ parameters: {
+ directory?: string
+ workspace?: string
+ sessionID: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "query", key: "sessionID" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get<
+ DeepagentPanelStatusResponses,
+ DeepagentPanelStatusErrors,
+ ThrowOnError
+ >({
+ url: "/deepagent/panel/status",
+ ...options,
+ ...params,
+ })
+ }
+}
+
+export class Goal extends HeyApiClient {
+ /**
+ * Start a Goal Loop from the session's plan
+ *
+ * V3.9 §D: materialize the session plan into the graded doc and drive the autonomous loop as a resident background task. Gated by the goal-loop flag.
+ */
+ public start(
+ parameters: {
+ directory?: string
+ workspace?: string
+ sessionID: string
+ objective?: string
+ criteria?: Array<{
+ kind: "tests_pass" | "no_diagnostics" | "reviewer_clean" | "panel_approves" | "plan_complete"
+ commands?: Array
+ maxSeverity?: string
+ severityAtMost?: string
+ }>
+ limits?: {
+ maxTicks?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ maxTokens?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ maxWallclockMs?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ maxCost?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ }
+ stallThreshold?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "sessionID" },
+ { in: "body", key: "objective" },
+ { in: "body", key: "criteria" },
+ { in: "body", key: "limits" },
+ { in: "body", key: "stallThreshold" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post