From 396961d75fb1b5ad40003db7295ecaee5822f2f6 Mon Sep 17 00:00:00 2001 From: sunheyi Date: Wed, 19 Aug 2026 16:35:17 +0800 Subject: [PATCH] stabilize LLM prefix caching context --- docs/README.md | 4 +- docs/failure-lessons-design.md | 14 + docs/memory-system-design.md | 11 +- docs/system-prompt-design.md | 336 +++++++------------ docs/tool-calling-design.md | 10 + src/main/ipc-handlers.ts | 12 +- src/main/runtime-projector.ts | 38 ++- src/shared/runtime-events.ts | 1 + src/shared/types.ts | 3 +- src/worker/agent/agent-loop.ts | 134 +++++--- src/worker/agent/agent.ts | 38 ++- src/worker/agent/compaction.ts | 5 +- src/worker/agent/context-budget.ts | 6 +- src/worker/agent/error-recovery.ts | 4 +- src/worker/agent/lessons.ts | 5 +- src/worker/agent/memory.ts | 3 +- src/worker/agent/model-structured-content.ts | 18 +- src/worker/agent/runtime-context.ts | 76 +++++ src/worker/agent/subagent.ts | 52 +-- src/worker/agent/system-prompt.ts | 52 +-- test/agent/guidance-injection.test.ts | 217 +++++++++++- test/agent/system-prompt.test.ts | 33 +- test/main/runtime-event-store.test.ts | 44 +++ test/worker/system-prompt.test.ts | 25 +- 24 files changed, 714 insertions(+), 427 deletions(-) create mode 100644 src/worker/agent/runtime-context.ts diff --git a/docs/README.md b/docs/README.md index 9f31eb3..853218d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,7 +5,7 @@ | 文档 | 说明 | |------|------| | [项目信息](project-info.md) | 运行时路径、数据目录、日志路径、构建信息、架构约定(Agent 回答"日志在哪""路径是什么"时优先读这个) | -| [系统提示词设计](system-prompt-design.md) | System Prompt 构建:角色定义、Tool Discipline 反循环规则、一行式工具摘要、XML 结构化上下文、Token 优化 | +| [系统提示词设计](system-prompt-design.md) | 稳定 System Prompt + 可追加 Runtime Context:KV 前缀缓存、真实用户识别、主 Agent/Subagent 继承与缓存 epoch | | [工具调用设计](tool-calling-design.md) | 工具架构:回合事件 (turn_start/end)、参数验证、前端工具卡片 (Command/File/Inspect)、数据流 | | [图片理解模型路由](vision-model-routing-design.md) | 视觉旁路:上下文隔离、自动能力识别、手动覆盖、同厂商模型选择与 `inspect_image` 调用链 | | [上下文压缩设计](context-compaction-design.md) | 容量保护 pipeline + 缓存友好的主动语义 projection:A/B/C 请求结构、失败保护、配置与 Terminal-Bench 验证 | @@ -46,7 +46,7 @@ | Agent turn 计数 | Agent 全局 turnCount 跨 run 累计,StatusBar 显示当前轮次 | | Tool Usage Discipline | 新增反循环规则章节,防止模型无限调用工具 | | 工具一行式摘要 | 替代完整 JSON Schema,节省 ~60% 工具 token | -| `` XML | 结构化注入 .agents.md / Skills / Memory / Lessons,遵循 pi/Codex 约定 | +| 结构化模型上下文 | `.agents.md` / Skills 位于稳定 system envelope;Memory / Lessons / Language 位于可追加 runtime context | | Skills 技能系统 | `~/.suncode/skills/` + 项目 `.suncode/skills/` 双层加载,Markdown 格式,注入 system prompt | | Memory 记忆系统 | 每次 session 结束自动生成记忆摘要存入 `.suncode/memories/`,下次对话根据语义检索注入上下文 | | Lessons 教训系统 | 失败时自动提取教训存入 `.suncode/lessons/`,后续相关任务检索注入,避免重复踩坑 | diff --git a/docs/failure-lessons-design.md b/docs/failure-lessons-design.md index 3bf4abf..0b6ecac 100644 --- a/docs/failure-lessons-design.md +++ b/docs/failure-lessons-design.md @@ -124,6 +124,20 @@ export interface LessonExtractionContext { --- +## 检索结果注入 + +`Agent.runLoop()` 和 `runGoalLoop()` 根据当前真实用户目标调用 `loadRelevantLessons()`。返回文本不再写入 system prompt,而是进入: + +```text +suncode.runtime_context.snapshot.relevantLessons +``` + +这样 lesson 检索结果变化时只追加新的运行时快照,不会破坏稳定的 system 前缀。主 Agent 会在每次运行开始前通过 `SubagentDispatcher.updateOptions()` 同步同一份 `relevantLessonsContent`,Subagent 再交由公共 `runAgentLoop()` 注入。 + +运行时上下文使用 user provider role,但带有 `contextKind='runtime_context'`;教训提取、用户纠错识别和记忆摘要必须通过 `isUserAuthoredMessage()` 排除该内部消息。 + +--- + ## Settings 扩展 ```typescript diff --git a/docs/memory-system-design.md b/docs/memory-system-design.md index 2c3390d..971bb22 100644 --- a/docs/memory-system-design.md +++ b/docs/memory-system-design.md @@ -61,6 +61,8 @@ SunCode 的记忆系统旨在帮助 AI agent 持久化和复用重要信息。 │ │ Agent 集成 │ │ │ │ agent.ts → loadMemoriesWithEntries() + relevanceJudge │ │ │ │ agent.ts → saveSessionMemory() / promoteExplicitDurableFacts() │ │ +│ │ agent-loop.ts → 检索内容进入 runtime_context │ │ +│ │ subagent.ts → 主 Agent 将同一份检索结果传给 Subagent │ │ │ │ agent-loop.ts → memoryReferences 随 finalMessage 持久化 │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ @@ -233,7 +235,7 @@ LLM 语义精排(可选) 记录访问(仅内存计数,写时落盘) │ ▼ -返回 { content: 注入 system prompt, entries: 引用展示 } +返回 { content: 注入 runtime_context, entries: 引用展示 } ``` ### 混合评分算法 @@ -460,7 +462,7 @@ Agent.prompt() ├── loadMemoriesWithEntries(query, { relevanceJudge }) │ ├── 常驻通道(主题门控)→ 普通检索 → LLM 精排 → 同源合并 │ └── 返回 { content, entries } - │ ├── content → 注入 system prompt + │ ├── content → 注入 runtime_context.snapshot.memory │ └── entries → recordMemoryAccess()(内存计数)+ 传递 UI │ ▼ @@ -488,6 +490,7 @@ Agent.saveSessionMemory() │ ▼ message_end 事件 + ├── runtime_context: memoryContent(主 Agent 与 Subagent 共用) └── memoryReferences: input.memoryEntries │ ▼ @@ -563,7 +566,9 @@ src/ │ │ └── getAllMemories() / getMemScenes() │ ├── agent-data-dir.ts ← SUNCODE_APP_DATA 路径统一解析 │ ├── agent.ts ← 记忆加载、saveSessionMemory、promoteExplicitDurableFacts -│ └── agent-loop.ts ← finalMessage 携带 memoryReferences 持久化 +│ ├── runtime-context.ts ← memory / lessons / language 运行时快照 +│ ├── subagent.ts ← 继承主 Agent 检索结果 +│ └── agent-loop.ts ← 注入 runtime_context;finalMessage 携带引用 ├── main/ │ ├── index.ts ← 设置 SUNCODE_APP_DATA、flat 记忆迁移 │ ├── preload.ts ← IPC API 定义 diff --git a/docs/system-prompt-design.md b/docs/system-prompt-design.md index 4eeea16..05540b5 100644 --- a/docs/system-prompt-design.md +++ b/docs/system-prompt-design.md @@ -1,256 +1,166 @@ -# 系统提示词设计文档 +# System Prompt 与运行时上下文设计 -## 1. 设计目标 +## 1. 目标 -系统提示词(System Prompt)是 AI coding agent 的核心指令集,决定了模型如何理解任务、使用工具、组织回答。设计目标: +SunCode 不显式管理供应商 KV 缓存,而是尽量让相邻模型请求满足“稳定前缀 + 只追加后缀”。供应商可据此前缀自动命中缓存。 -- **角色清晰**:让模型明确自己是 coding agent,而非通用助手 -- **行为约束**:规范模型如何探索代码、编辑文件、执行命令 -- **工具引导**:教会模型何时使用哪个工具,如何正确传参 -- **安全边界**:防止破坏性操作(如 `rm -rf /`) -- **可扩展**:支持 Skills 注入领域知识,支持 MCP 动态工具 +核心原则: ---- +- 高权限、低频变化的指令进入 system prompt。 +- 每轮可能变化的状态进入内部 `runtime_context` 用户角色消息。 +- 工具调用产生的事实保留在 assistant/tool 消息中,不回写 system prompt。 +- 内部上下文消息不能被业务逻辑误判为用户的新指令。 +- 主 Agent 与 Subagent 使用同一套构造规则。 -## 2. 提示词结构 +## 2. 请求结构 +```text +system: suncode.system_prompt 稳定请求头 +user: suncode.runtime_context(按变化追加) 可信运行时状态 +user: 用户真实请求 +assistant/tool: 历史执行链 +user: 下一条真实请求 ``` -┌─────────────────────────────────────┐ -│ 1. 角色定义 (Identity) │ ← 你是谁 -├─────────────────────────────────────┤ -│ 2. 能力说明 (Capabilities) │ ← 你能做什么 -├─────────────────────────────────────┤ -│ 3. 行为准则 (Guidelines) │ ← 你应该怎么做 -├─────────────────────────────────────┤ -│ 4. 工具调度纪律 (Tool Discipline) │ ← ★ 最关键的反循环规则 -├─────────────────────────────────────┤ -│ 5. 环境信息 (Environment) │ ← 你在哪里工作 -├─────────────────────────────────────┤ -│ 6. (如有) │ ← .agents.md 项目约束 (XML) -├─────────────────────────────────────┤ -│ 7. 工具摘要 (Tools - 一行式) │ ← ★ 省 token 的工具摘要 -├─────────────────────────────────────┤ -│ 8. (如有) │ ← Skills 领域知识 (XML) -├─────────────────────────────────────┤ -│ 9. 开始指令 (Begin) │ ← 开始工作吧 -└─────────────────────────────────────┘ -``` - ---- - -## 3. 各模块设计详解 - -### 3.1 角色定义 +`system prompt` 与工具定义在一次运行开始时构建一次。同一运行内的后续模型请求复用完全相同的 system 字符串和确定性排序后的工具定义。 + +### 2.1 `suncode.system_prompt` + +由 `system-prompt.ts` 和 `model-structured-content.ts` 生成,主要字段: + +```json +{ + "type": "suncode.system_prompt", + "version": 1, + "basePrompt": "...", + "agentRolePrompt": "...", + "mode": { "permissionMode": "full_access" }, + "guidelines": [], + "tools": [], + "context": { + "projectInstructions": "...", + "skills": "...", + "projectKnowledge": "..." + }, + "environment": { "workingDirectory": "..." } +} ``` -You are SunCode, an expert software engineer AI assistant running as a desktop application. -Your purpose is to help users write, understand, debug, and refactor code. -``` - -**设计要点**: -- 用 `expert software engineer` 而非 `coding assistant`,引导模型以高级工程师思维工作 -- 明确"桌面应用"身份,区别于浏览器环境 -- 目标动词清晰:write / understand / debug / refactor - -### 3.2 能力说明 - -简明列出模型能执行的操作类型,这帮助模型在决策时知道自己能做什么: - -- 读/写/编辑/搜索文件 -- 执行 shell 命令 -- 分析代码库结构 -- 提供解释和建议 - -### 3.3 行为准则 - -这是直接影响模型行为质量的部分,每条准则都解决一类常见问题: - -| # | 准则 | 解决的问题 | -|---|------|-----------| -| 1 | Be concise but thorough | 防止过度啰嗦 | -| 2 | Show before/after diff when editing | 让用户可视化变更 | -| 3 | Explain reasoning before changes | 建立信任,可审查 | -| 4 | Gather info before answering | 防止模型假设文件内容 | -| 5 | Explain root cause before fixing bugs | 教育用户,防止表面修复 | -| 6 | Read files instead of assuming | **最关键**:防止幻觉 | -| 7 | Respect existing code style | 保持代码库一致性 | -| 8 | Include clear command descriptions | bash 工具要求 | -| 9 | Ask for clarification, don't guess | 防止错误操作 | -| 10 | Use parallel tool calls | 提升效率 | - -### 3.4 工具调度纪律 (Tool Usage Discipline) ★ 新增 - -这是 2026-06 针对 DeepSeek 等模型反复调用工具的循环问题新增的关键章节, -参考 Codex / pi 项目的反循环设计。分三个子节: - -**When to STOP using tools** — 明确"什么时候该停手" -- 1-3 次工具调用后必须评估是否已够回答 -- 浏览类请求("查看项目结构")最多 2 次工具调用后就汇总 -- Bug 修复:读相关文件 → 找根因 → 修 → 停 -- 如果发现自己要重复执行同一个命令,立即停止 - -**Anti-looping rules** — 防止无限工具循环 -- 同一命令不准执行两次 -- 信息类请求不准超过 2-3 轮 -- 找到答案后不准继续探索无关文件 -- 不确定时直接回应"我找到了这些,需要深入吗?" - -**Response format for informational requests** — 结构化回答格式 -- 浏览类请求必须输出:高层概述 + 组织好的信息 + 可选的深入提示 -- 明确告知:每次额外的工具调用都在消耗用户时间 - -### 3.5 工具摘要 (一行式) ★ 重新设计 | - -### 3.4 环境信息 -``` -## Environment -- Working directory: /Users/user/project -- Operating system: darwin -- Date: 2026-06-22 -- Maximum turns: 50 +其中 `agentRolePrompt` 仅在命名 Subagent 中存在,用来保存该 Agent 的稳定角色约束。它属于 system 权限层,不能作为普通 user 消息追加。 + +### 2.2 `suncode.runtime_context` + +由 `runtime-context.ts` 生成: + +```json +{ + "type": "suncode.runtime_context", + "version": 1, + "snapshot": { + "memory": "...", + "relevantLessons": "...", + "responseLanguage": { + "language": "zh", + "instruction": "..." + }, + "currentDate": "2026-08-19" + }, + "semantics": { + "authority": "trusted_runtime_state", + "supersedesPriorRuntimeContext": true, + "userAuthored": false + } +} ``` -动态注入运行时环境变量,让模型感知其所处的环境。`Maximum turns` 限制让模型知道它有有限的操作次数,促使其高效工作。 +它在 provider API 中使用 `user` 角色,但 `Message.contextKind === 'runtime_context'`。所有需要寻找“真实用户”的逻辑必须使用 `isUserAuthoredMessage()`,不能只判断 `role === 'user'`。 -### 3.5 工具摘要 (一行式) ★ 重新设计 +插入策略: -**旧设计**:每个工具展开完整 JSON Schema(~200 字符/工具,6 工具 ≈ 1200+ 字符), -占用大量系统提示 token。 +1. 构造当前快照的确定性 JSON。 +2. 与历史中最近的 runtime context 比较完整内容。 +3. 内容相同则不追加。 +4. 内容变化则插入当前真实用户消息之前。 -**新设计**:参考 pi 项目的 `toolSnippets` 方案,每个工具用一行摘要描述: +因此一次跨轮请求通常形如: +```text +R1, U1, A1, U2 ``` -- **read**: Read file contents with line numbers. `file_path` (required), `offset`, `limit`. -- **write**: Create or overwrite a file. `file_path` and `content` required. -- **edit**: Exact string replacement. `file_path`, `old_string`, `new_string` required. -- **bash**: Execute a shell command. `command` (required), `description`, `timeout`. -- **grep**: Regex search via ripgrep. `pattern` (required), `path`, `glob`, `type`. -- **glob**: Find files by glob pattern. `pattern` required (e.g. "**/*.ts"). -``` - -完整 JSON Schema 仍然通过 `tool.getDefinition()` 提供给 function-calling -provider 的 API 层使用,但不再占用系统提示 token。 - -**Token 节省**:旧格式 ~1200 chars → 新格式 ~450 chars,节省 ~60%。 -**关键设计**: -- 每行包含:`name` + 一句话功能 + 必需参数列表 -- 参数用反引号标注,一目了然 -- 按照 pi 项目的实践,LLM 不需要完整 Schema 也能正确选工具——provider 的 function calling 机制会处理参数详情 - -### 3.6 工具使用指南 - -明确的工具使用规则,列举了常见的正确/错误用法: +若运行时状态变化,则为: -``` -1. You may call multiple tools in a single response when operations are independent. -2. Always read files before editing them - never assume file contents. -3. When making edits, use the edit tool with exact string matching. -4. When executing bash commands, include clear descriptions of what each command does. -5. Search for code patterns with grep before making broad changes. -6. If a tool returns an error, analyze the error and adjust your approach. -7. After completing all necessary changes, respond with a summary of what was done. +```text +R1, U1, A1, R2, U2 ``` -### 3.7 结构化上下文注入 +`R2` 声明覆盖旧快照,但旧消息不被原地修改,保证已有前缀仍然稳定。 -Skills 和 Workspace 指令采用结构化 XML 标签组织,遵循 pi / Codex 的设计约定: +## 3. 主 Agent -**``** — 来自 `.agents.md` 的项目级约束: -```xml - - -- 代码风格要求 -- 部署规则 -- 安全约束 - -``` +`Agent.runLoop()` 在处理真实用户请求后加载: -**``** — 来自 Skill 文件的领域知识: -```xml - - -- 特定框架的编码规范 -- 项目工具链的使用说明 - -``` +- 工作区指令与 Skills:静态 system context。 +- 检索后的 memory:runtime context。 +- 当前任务相关 lessons:runtime context。 +- 当前 UI 回复语言:runtime context。 -XML 标签让模型能清晰区分"通用指令"和"项目特定规则"。 -参考 [agentskills.io](https://agentskills.io) 约定和 pi 项目的 `` 实践。 +`runAgentLoop()` 会保留完整 provider-facing 历史,包括 assistant tool call 与 tool result。正常完成后,下一次请求在该历史后追加最终回答和新用户消息,不再压扁成“用户请求 + 最终回答”。 -### 3.8 `.agents.md` 加载 +运行事件中的 `runtime_context_committed` 会写入 session ledger。`runtime-projector.ts` 维护两个投影: -遵循 Codex 约定,加载两级 `.agents.md`: -1. 项目级:`/.agents.md`(fallback `AGENTS.md`) -2. 用户级:`~/.agents.md` +- `messages`:Renderer 可见消息,不包含 runtime context。 +- `modelMessages`:Worker 恢复模型历史时使用,包含 runtime context。 -两者合并后注入 `` 标签。该内容通过 -`AgentLoopInput.agentsMdContent` 传入 `buildSystemPrompt()`。 +## 4. Subagent ---- +Subagent 复用 `runAgentLoop()`,不自行拼接第二份 system prompt: -## 4. 提示词生成流程 +- `agentRolePrompt` 保存 `SubagentDefinition.systemPrompt`,不会被公共循环覆盖。 +- `memoryContent`、`relevantLessonsContent`、`responseLanguage` 进入同一种 runtime context。 +- `parentMessages` 与当前 `AbortSignal` 在每次主 Agent 运行开始时刷新。 +- 命名会话保存 `result.modelMessages`,保留完整工具链供下一次同名调用追加。 +- 已有命名会话历史时不重复注入 parent context。 +- Subagent 的私有 runtime context 不提交到主 session ledger,避免污染主 Agent 的模型投影。 -``` -Agent.runLoop() - │ - ├─ loadAgentsMd(workingDir) // .agents.md / AGENTS.md - ├─ skillsLoader.loadAll() // Skills 内容 - │ - ├─ buildSystemPrompt({ - │ workingDir, // 进程 cwd - │ tools, // ToolRegistry.getDefinitions() - │ skillsContent, // SkillsLoader.loadAll() - │ maxTurns, // 用户设置 - │ agentsMdContent, // .agents.md 内容 (v2026-06 新增) - │ customPrompt, // 可选的自定义提示词 - │ }) - │ - ├─ getToolSnippet() // 一行工具摘要(替代完整 JSON Schema) - │ - ├─ 结构化注入: - │ ├─ agentsMdContent - │ └─ skillsContent - │ - └─ 放入 piContext.systemPrompt → 发送给 LLM -``` +临时 Subagent 没有跨调用历史,但仍能共享稳定 system header,并在单次执行内保持追加式请求。 -### 提示词大小对比 +## 5. 缓存 epoch -| 部分 | 旧设计 (chars) | 新设计 (chars) | 节省 | -|------|---------------|---------------|------| -| 工具 Schema | ~1200 | ~450 | -62% | -| 反循环规则 | 0 | ~600 | 新增 | -| 结构化 XML 标签 | 0 | ~50 | 新增 | -| **总计** | ~3400 | ~2800 | -18% | +以下变化会自然开启新的缓存 epoch,因为它们改变 system prompt 或工具定义: -虽然加了反循环规则,因工具摘要大幅缩减,总提示词反而减少了 ~18%。 +- 默认或自定义 base prompt 改变。 +- 工作目录、项目指令、Skills、Project Knowledge 改变。 +- Subagent 角色定义改变。 +- 可用工具集合、描述或参数 Schema 改变。 +- 权限模式改变。 ---- +memory、lessons、日期、回复语言变化不会重写 system prompt,只会追加新的 runtime context。 -## 5. 经验总结 +上下文压缩或语义 projection 会有意改写模型请求视图,也应视为新的历史 epoch,而不是缓存异常。 -### ✅ 有效实践 +## 6. TurnEvidence 边界 -1. **版本化提示词**:系统提示词应像代码一样版本管理,每次改动记录效果 -2. **先读后写**:Rule #6 重复了 3 次(Guideline #6 + Tool Guideline #2 + Rule #2),因为这是模型最常见的错误 -3. **显式优于隐式**:工具参数说明中包含"默认值是多少""合法范围是什么" -4. **约束工具选择**:告诉模型在哪些场景下用哪个工具,而不是让模型自己猜 +`TurnEvidenceBuffer` 用于完成门校验和证据索引,不进入 system prompt。模型看到的工具事实以 assistant/tool 消息为准;把 evidence 再注入 system 会同时造成权限混淆、内容重复和缓存失效。 -### ❌ 常见反模式 +## 7. 关键实现 -1. **过度约束**:太多"Don't"规则会让模型变得被动、不敢行动 -2. **缺少示例**:工具用法只有 Schema 没有示例,模型容易传错参数 -3. **忽略容错**:不告诉模型"工具失败了该怎么办",它可能循环重试同一个错误参数 -4. **提示词过长**:系统提示词占 context window 太大比例,压缩了对话空间 +| 文件 | 职责 | +|------|------| +| `src/worker/agent/system-prompt.ts` | 构建稳定 system envelope,确定性排序工具 | +| `src/worker/agent/model-structured-content.ts` | 结构化 JSON 序列化 | +| `src/worker/agent/runtime-context.ts` | 构造、去重和插入 runtime context;识别真实用户 | +| `src/worker/agent/agent-loop.ts` | 冻结请求头、保留 provider-facing 历史 | +| `src/worker/agent/subagent.ts` | Subagent 角色、上下文继承和命名历史 | +| `src/main/runtime-projector.ts` | 分离 UI 消息投影与模型历史投影 | -### 📊 提示词占比 +## 8. 测试约束 -| 模型 | Context Window | 典型 System Prompt | 占比 | -|------|---------------|-------------------|------| -| Claude Sonnet 4.5 | 200K | ~3K tokens | 1.5% | -| GPT-5.1 Codex | 128K | ~3K tokens | 2.3% | -| Gemini 2.5 Pro | 1M | ~3K tokens | 0.3% | +测试应验证行为而不是具体提示词文案: -保持系统提示词在 2000-4000 tokens 是最佳范围。 +- 同一运行的 system prompt 和 tools 完全相同。 +- 后一请求以前一请求消息为精确前缀。 +- runtime context 相同不重复,变化时追加。 +- memory、lessons、language 不出现在 system prompt。 +- runtime context 不被当作真实用户,也不出现在 UI 投影。 +- Subagent 的角色仍位于 system envelope,私有 runtime context 不进入主 ledger。 diff --git a/docs/tool-calling-design.md b/docs/tool-calling-design.md index 66a6156..7546baf 100644 --- a/docs/tool-calling-design.md +++ b/docs/tool-calling-design.md @@ -81,6 +81,16 @@ └─────────────────────────────────────────────┘ ``` +### 2.1 Subagent 请求上下文 + +`subagent` 工具由 `SubagentDispatcher` 执行,最终仍进入公共 `runAgentLoop()`: + +- Subagent 的角色定义通过稳定的 `agentRolePrompt` 进入 system envelope。 +- 主 Agent 检索出的 memory、lessons 和当前 response language 通过 `runtime_context` 继承。 +- 命名 Subagent 会话保存完整 `modelMessages`,包括 assistant tool call 与 tool result;下一次同名调用只追加新任务。 +- 命名历史存在时不重复复制 parent context。 +- Subagent 私有 runtime context 不提交到主会话 ledger,防止主 Agent 的恢复投影被委托上下文污染。 + --- ## 3. 工具接口设计 diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 342bb71..dabb5e6 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -339,6 +339,16 @@ function semanticDraftFromRunEvent( }; } return null; + case 'runtime_context_committed': + return { + ...base, + eventId: `${context.runId}:runtime-context:${stableContentToken( + typeof event.message.content === 'string' + ? event.message.content + : JSON.stringify(event.message.content), + )}`, + fact: { type: 'runtime_context_committed', message: event.message }, + }; default: return null; } @@ -1035,7 +1045,7 @@ export function registerIpcHandlers(wm: WindowManager): void { if (runtimeEvents.length > 0 && meta) { const projection = projectRuntimeSession(id, runtimeEvents); messages = projection.messages; - modelHistory = projection.messages; + modelHistory = projection.modelMessages; const cursorUnchanged = projectionCursorMatches(meta.runtimeProjection, projection.cursor); const countUnchanged = meta.messageCount === messages.length; meta.runtimeProjection = projection.cursor; diff --git a/src/main/runtime-projector.ts b/src/main/runtime-projector.ts index 3fb5621..a926c5f 100644 --- a/src/main/runtime-projector.ts +++ b/src/main/runtime-projector.ts @@ -14,6 +14,7 @@ interface RunProjectionState { export interface SessionRuntimeProjection { messages: Message[]; + modelMessages: Message[]; cursor: RuntimeProjectionCursor; } @@ -45,6 +46,7 @@ export interface IncrementalSessionRuntimeProjector { /** Apply only newly appended facts while retaining per-run projection state. */ export function createSessionRuntimeProjector(): IncrementalSessionRuntimeProjector { let messages: Message[] = []; + let modelMessages: Message[] = []; const runs = new Map(); let latestUiLanguage: Message['uiLanguage']; let lastEventId: string | undefined; @@ -58,22 +60,43 @@ export function createSessionRuntimeProjector(): IncrementalSessionRuntimeProjec const runId = event.runId; switch (event.fact.type) { case 'legacy_snapshot_imported': - messages = event.fact.messages.map(cloneMessage); + modelMessages = event.fact.messages.map(cloneMessage); + messages = modelMessages + .filter((message) => message.contextKind !== 'runtime_context') + .map(cloneMessage); latestUiLanguage = [...messages] .reverse() - .find((message) => message.role === 'user')?.uiLanguage; + .find( + (message) => message.role === 'user' && message.contextKind === undefined, + )?.uiLanguage; break; case 'conversation_cleared': messages = []; + modelMessages = []; latestUiLanguage = undefined; break; case 'user_message_committed': messages.push(cloneMessage(event.fact.message)); + modelMessages.push(cloneMessage(event.fact.message)); if (runId) runState(runs, runId).hasVisibleUserMessage = true; if (event.fact.message.uiLanguage !== undefined) { latestUiLanguage = event.fact.message.uiLanguage; } break; + case 'runtime_context_committed': { + const message = cloneMessage(event.fact.message); + let currentUserIndex = -1; + for (let index = modelMessages.length - 1; index >= 0; index--) { + const candidate = modelMessages[index]; + if (candidate?.role === 'user' && candidate.contextKind === undefined) { + currentUserIndex = index; + break; + } + } + const insertionIndex = currentUserIndex >= 0 ? currentUserIndex : modelMessages.length; + modelMessages.splice(insertionIndex, 0, message); + break; + } case 'system_prompt_committed': if (runId) runState(runs, runId).systemPrompt = event.fact.systemPrompt; break; @@ -141,6 +164,13 @@ export function createSessionRuntimeProjector(): IncrementalSessionRuntimeProjec } else { messages.push(message); } + const modelMessage = cloneMessage(message); + const lastModelMessage = modelMessages.at(-1); + if (!state?.hasVisibleUserMessage && lastModelMessage?.role === 'assistant') { + modelMessages[modelMessages.length - 1] = { ...lastModelMessage, ...modelMessage }; + } else { + modelMessages.push(modelMessage); + } break; } case 'permission_requested': @@ -159,6 +189,7 @@ export function createSessionRuntimeProjector(): IncrementalSessionRuntimeProjec return { messages, + modelMessages, cursor: { version: RUNTIME_PROJECTION_VERSION, lastEventId, @@ -185,8 +216,7 @@ export function projectSessionRuntime(events: RuntimeEvent[]): SessionRuntimePro /** Current compatibility policy intentionally preserves the existing Message[] request shape. */ export function projectModelHistory(events: RuntimeEvent[]): Message[] { - // projectSessionRuntime already clones messages into a fresh array. - return projectSessionRuntime(events).messages; + return projectSessionRuntime(events).modelMessages; } /** Classify invocation state from semantic facts without consulting run headers or UI state. */ diff --git a/src/shared/runtime-events.ts b/src/shared/runtime-events.ts index 63be1bc..4ec5d15 100644 --- a/src/shared/runtime-events.ts +++ b/src/shared/runtime-events.ts @@ -13,6 +13,7 @@ export type RuntimeEventFact = source: 'dispatch' | 'guidance' | 'recovery'; } | { type: 'system_prompt_committed'; systemPrompt: string } + | { type: 'runtime_context_committed'; message: Message } | { type: 'model_step_committed'; stepIndex: number; diff --git a/src/shared/types.ts b/src/shared/types.ts index 011bb63..39575aa 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -55,7 +55,7 @@ export interface Message { role: MessageRole; content: string | ContentBlock[]; /** Runtime-only model context projection; not a user-authored message. */ - contextKind?: 'capacity_summary' | 'semantic_projection'; + contextKind?: 'capacity_summary' | 'semantic_projection' | 'runtime_context'; toolCallId?: string; /** UI language selected from the user prompt for localized progress display. */ uiLanguage?: UiLanguage; @@ -855,6 +855,7 @@ export type RunEvent = | { type: 'run_failed'; runId: RunId; error: string; timestamp: string } | { type: 'run_aborted'; runId: RunId; timestamp: string } | { type: 'guidance_injected'; runId: RunId; text: string; timestamp: string } + | { type: 'runtime_context_committed'; runId: RunId; message: Message; timestamp: string } | { type: 'completion_gate_blocked'; runId: RunId; diff --git a/src/worker/agent/agent-loop.ts b/src/worker/agent/agent-loop.ts index 194a4d7..2bd3165 100644 --- a/src/worker/agent/agent-loop.ts +++ b/src/worker/agent/agent-loop.ts @@ -36,6 +36,7 @@ import { quickMatchLesson } from './lessons'; import { buildStructuredTaskPrompt, buildStructuredTextMessage } from './model-structured-content'; import { createProgressGuardState, updateSimpleTaskProgressGuard } from './progress-guard'; import { prepareProjectKnowledge } from './project-knowledge'; +import { appendRuntimeContextIfChanged, isUserAuthoredMessage } from './runtime-context'; import { applySemanticProjection, buildSemanticCompactRequest, @@ -44,7 +45,7 @@ import { } from './semantic-compact'; import { handleStream } from './stream-handler'; import { StreamingToolExecutor } from './streaming-executor'; -import { buildSystemPrompt } from './system-prompt'; +import { buildSystemPrompt, sortToolDefinitions } from './system-prompt'; import { isSimpleTask } from './task-policy'; import { executeTools } from './tool-executor'; import { formatToolResultForModel } from './tool-result-content'; @@ -77,6 +78,8 @@ export interface AgentLoopInput { settings: AppSettings; workingDir: string; skillsContent: string; + /** Stable role/task policy for a named sub-agent. */ + agentRolePrompt?: string; /** Content from .agents.md / AGENTS.md (Codex-style workspace instructions). */ agentsMdContent?: string; /** Auto-generated memories from prior sessions. */ @@ -87,6 +90,8 @@ export interface AgentLoopInput { relevantLessonsContent?: string; /** User-facing response language derived from the current user prompt. */ responseLanguage?: UiLanguage; + /** Sub-agent runs share the parent ledger and must not project private context into it. */ + emitRuntimeContextEvent?: boolean; abortSignal: AbortSignal; /** Unique identifier for this run (used for event logging). */ runId: string; @@ -134,6 +139,8 @@ export interface PrepareNextTurnResult { export interface AgentLoopResult { finalMessage: Message; + /** Provider-facing history retained for an append-only continuation in the next run. */ + modelMessages: Message[]; turnCount: number; tokenUsage: { input: number; output: number; total: number }; /** Structured decision about why the loop terminated. */ @@ -168,10 +175,12 @@ export async function runAgentLoop(input: AgentLoopInput): Promise message.role === 'user'); + const headAnchor = [...contextMessages].reverse().find(isUserAuthoredMessage); const latestUserPrompt = headAnchor ? getMessageTextContent(headAnchor) : ''; const guardSimpleTask = isSimpleTask(latestUserPrompt); let progressGuardState = createProgressGuardState(); @@ -230,6 +252,25 @@ export async function runAgentLoop(input: AgentLoopInput): Promise tool.getDefinition())); + const systemPrompt = buildSystemPrompt({ + workingDir, + tools: toolDefs, + skillsContent, + agentRolePrompt, + permissionMode: 'full_access', + agentsMdContent, + projectKnowledge, + }); + if (contextMessages[0]?.role === 'system') { + contextMessages[0] = { role: 'system', content: systemPrompt }; + } else { + contextMessages.unshift({ role: 'system', content: systemPrompt }); + } // Diagnostic logger: persists to .suncode/diagnostics/.log const diag = new DiagLogger(workingDir, runId); @@ -238,7 +279,7 @@ export async function runAgentLoop(input: AgentLoopInput): Promise t.getDefinition()); - const turnEvidenceContent = turnEvidence.formatPromptWindow(); - const systemPrompt = buildSystemPrompt({ - workingDir, - tools: toolDefs, - skillsContent, - permissionMode: 'full_access', - agentsMdContent, - memoryContent, - relevantLessonsContent, - turnEvidenceContent: turnEvidenceContent || undefined, - responseLanguage, - projectKnowledge, - }); - if (systemPrompt !== lastSystemPrompt) { - if (contextMessages[0]?.role === 'system') { - contextMessages[0] = { role: 'system', content: systemPrompt }; - } else { - contextMessages.unshift({ role: 'system', content: systemPrompt }); - } - lastSystemPrompt = systemPrompt; + if (!systemPromptEmitted) { + systemPromptEmitted = true; onStream({ type: 'system_prompt', systemPrompt }); } @@ -534,13 +555,18 @@ export async function runAgentLoop(input: AgentLoopInput): Promise message.role !== 'system'), + finalMessage, + ], turnCount, tokenUsage, decision: { decision: 'stop', reason: 'blocked', taxonomy: 'blocked' }, @@ -663,13 +689,18 @@ export async function runAgentLoop(input: AgentLoopInput): Promise message.role !== 'system'), + finalMessage, + ], turnCount, tokenUsage, decision: turnDecision, @@ -1082,18 +1113,23 @@ export async function runAgentLoop(input: AgentLoopInput): Promise message.role !== 'system'), + finalMessage, + ], turnCount, tokenUsage, decision: { decision: 'stop', reason: 'max_turns', taxonomy: 'max_turns_exhausted' }, @@ -1127,7 +1163,7 @@ function convertMessage(msg: Message): Record { } if (msg.contextKind) { - if (msg.contextKind === 'semantic_projection') { + if (msg.contextKind === 'semantic_projection' || msg.contextKind === 'runtime_context') { return { role: 'user' as const, content: getMessageTextContent(msg), diff --git a/src/worker/agent/agent.ts b/src/worker/agent/agent.ts index 032ac99..2ac650e 100644 --- a/src/worker/agent/agent.ts +++ b/src/worker/agent/agent.ts @@ -52,7 +52,7 @@ import { saveSessionSnapshot, updateMemory, } from './memory'; - +import { isUserAuthoredMessage } from './runtime-context'; import { createSkillsLoader, preloadSkills } from './skills'; import { createDefaultStopHookRegistry } from './stop-hooks'; import { SubagentDispatcher } from './subagent'; @@ -555,7 +555,7 @@ export class Agent { ): Promise<{ model: unknown; directResponse?: string }> { const imageMessages = this.messages.filter( (message) => - message.role === 'user' && + isUserAuthoredMessage(message) && Array.isArray(message.content) && message.content.some((block) => block.type === 'image'), ); @@ -563,7 +563,8 @@ export class Agent { const latestMessage = this.messages.at(-1); const latestImageMessage = - latestMessage?.role === 'user' && + latestMessage !== undefined && + isUserAuthoredMessage(latestMessage) && Array.isArray(latestMessage.content) && latestMessage.content.some((block) => block.type === 'image') ? latestMessage @@ -732,8 +733,13 @@ export class Agent { ); // Share retrieved context with sub-agents - this.dispatcher?.updateMemoryContent(memoryContent); - this.dispatcher?.updateRelevantLessonsContent(relevantLessonsContent); + this.dispatcher?.updateOptions({ + parentMessages: this.messages, + abortSignal: this.abortController!.signal, + memoryContent, + relevantLessonsContent, + responseLanguage: this.currentResponseLanguage, + }); // Summary mode is one text-only turn. Ordinary small edits get a tighter // turn/thinking policy so a global xhigh setting cannot make them sprawl. @@ -863,8 +869,9 @@ export class Agent { taxonomy: result.decision.decision === 'stop' ? (result.decision.taxonomy as any) : undefined, }); - // Add assistant message to history - this.messages.push(result.finalMessage); + // Preserve the exact provider-facing tool chain so the next user request + // extends this run instead of rebuilding a shorter, cache-breaking view. + this.messages = result.modelMessages; // Emit done this.onDone(result.finalMessage); @@ -945,8 +952,13 @@ export class Agent { ); // Share retrieved context with sub-agents - this.dispatcher?.updateMemoryContent(memoryContent); - this.dispatcher?.updateRelevantLessonsContent(relevantLessonsContent); + this.dispatcher?.updateOptions({ + parentMessages: this.messages, + abortSignal: this.abortController!.signal, + memoryContent, + relevantLessonsContent, + responseLanguage: this.currentResponseLanguage, + }); const contextBudgetPolicy = buildContextBudgetPolicy( this.settings, @@ -1123,7 +1135,7 @@ export class Agent { /** Save a summary of the current session to .suncode/memories/. */ private async saveSessionMemory(): Promise { try { - const lastUserMsg = [...this.messages].reverse().find((m) => m.role === 'user'); + const lastUserMsg = [...this.messages].reverse().find(isUserAuthoredMessage); if (!lastUserMsg) return; const userRequest = @@ -1415,7 +1427,7 @@ function sameFactStem(left: StructuredFact, right: StructuredFact): boolean { } function latestUserText(messages: Message[]): string { - const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user'); + const lastUserMsg = [...messages].reverse().find(isUserAuthoredMessage); if (!lastUserMsg) return ''; return typeof lastUserMsg.content === 'string' @@ -1434,7 +1446,7 @@ function recentUserText(messages: Message[], count = 3, maxLength = 600): string const texts: string[] = []; for (let i = messages.length - 1; i >= 0 && texts.length < count; i--) { const message = messages[i]!; - if (message.role !== 'user') continue; + if (!isUserAuthoredMessage(message)) continue; const text = typeof message.content === 'string' ? message.content @@ -1448,7 +1460,7 @@ function recentUserText(messages: Message[], count = 3, maxLength = 600): string } function inferLatestUiLanguage(messages: Message[]): UiLanguage { - const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user'); + const lastUserMsg = [...messages].reverse().find(isUserAuthoredMessage); return lastUserMsg?.uiLanguage ?? 'zh'; } diff --git a/src/worker/agent/compaction.ts b/src/worker/agent/compaction.ts index 4fd0ec5..b319877 100644 --- a/src/worker/agent/compaction.ts +++ b/src/worker/agent/compaction.ts @@ -1,5 +1,6 @@ import { CHARS_PER_TOKEN, CONTEXT_SAFETY_MARGIN } from '@shared/constants'; import type { Message, TextContent, ToolCallContent } from '@shared/types'; +import { isUserAuthoredMessage } from './runtime-context'; /** * Estimate the number of tokens in a message or text. @@ -69,7 +70,7 @@ export function compactMessages( let currentTurn: Message[] = []; for (const msg of nonSystemMessages) { - if (msg.role === 'user' && currentTurn.length > 0) { + if (isUserAuthoredMessage(msg) && currentTurn.length > 0) { turns.push(currentTurn); currentTurn = []; } @@ -114,7 +115,7 @@ function summarizeTurns(turns: Message[][]): string { const summaryParts: string[] = []; for (const turn of turns) { - const userMsg = turn.find((m) => m.role === 'user'); + const userMsg = turn.find(isUserAuthoredMessage); const assistantMsgs = turn.filter((m) => m.role === 'assistant'); if (userMsg) { diff --git a/src/worker/agent/context-budget.ts b/src/worker/agent/context-budget.ts index 47046d1..64bcb55 100644 --- a/src/worker/agent/context-budget.ts +++ b/src/worker/agent/context-budget.ts @@ -21,6 +21,7 @@ import type { } from '@shared/types'; import { countStringTokens } from '../utils/token-counter'; import { compactMessages } from './compaction'; +import { isUserAuthoredMessage } from './runtime-context'; import { archiveToolResultBody } from './tool-result-archive'; // ============================================================================ @@ -79,7 +80,8 @@ export function applyContextBudget( droppedTurns = turnGroups.length - keptTurnIds.size; const msgTurnMap = buildMessageTurnMap(working); working = working.filter((msg) => { - if (msg.role === 'system' || msg.role === 'user') return true; + if (msg.role === 'system' || msg.contextKind === 'runtime_context') return true; + if (isUserAuthoredMessage(msg)) return true; const turnId = msgTurnMap.get(msg); return turnId ? keptTurnIds.has(turnId) : true; }); @@ -246,7 +248,7 @@ export function groupMessagesByTurn( continue; } - if (msg.role === 'user') { + if (isUserAuthoredMessage(msg)) { flushCurrentGroup(); } else if (msg.role === 'assistant' && currentGroup.some((item) => item.role === 'assistant')) { flushCurrentGroup(); diff --git a/src/worker/agent/error-recovery.ts b/src/worker/agent/error-recovery.ts index 554a3f2..41e0e75 100644 --- a/src/worker/agent/error-recovery.ts +++ b/src/worker/agent/error-recovery.ts @@ -14,6 +14,7 @@ import { RECOVERY_MAX_OUTPUT_TOKENS, } from '@shared/constants'; import type { ContinueSite, RecoveryContext } from '@shared/types'; +import { isUserAuthoredMessage } from './runtime-context'; // ===== Error Classification ===== @@ -188,7 +189,8 @@ export function emergencyCompact( // Find last 3 user messages and keep everything after the third-to-last const userIndices: number[] = []; for (let i = 0; i < messages.length; i++) { - if (messages[i].role === 'user') userIndices.push(i); + const message = messages[i]; + if (message && isUserAuthoredMessage(message)) userIndices.push(i); } if (userIndices.length <= 3) { diff --git a/src/worker/agent/lessons.ts b/src/worker/agent/lessons.ts index 9f811e1..516e0c6 100644 --- a/src/worker/agent/lessons.ts +++ b/src/worker/agent/lessons.ts @@ -36,6 +36,7 @@ import type { } from '@shared/types'; import { getAgentDataSubdir } from './agent-data-dir'; import { buildStructuredTaskPrompt } from './model-structured-content'; +import { isUserAuthoredMessage } from './runtime-context'; // ---- Paths ---- @@ -551,7 +552,7 @@ export function buildExtractionContexts( for (let i = 1; i < messages.length; i++) { const userMsg = messages[i]!; const prevMsg = messages[i - 1]!; - if (userMsg.role !== 'user') continue; + if (!isUserAuthoredMessage(userMsg)) continue; if (prevMsg.role !== 'assistant' || !prevMsg.toolCalls?.length) continue; const text = @@ -657,7 +658,7 @@ function buildExtractionUserPrompt(ctx: LessonExtractionContext): string { // Try to find the original user message for (const msg of ctx.relevantMessages) { - if (msg.role === 'user') { + if (isUserAuthoredMessage(msg)) { const text = typeof msg.content === 'string' ? msg.content diff --git a/src/worker/agent/memory.ts b/src/worker/agent/memory.ts index a9216f3..0704107 100644 --- a/src/worker/agent/memory.ts +++ b/src/worker/agent/memory.ts @@ -15,6 +15,7 @@ import { join } from 'node:path'; import type { Message } from '@shared/types'; import { getAgentDataSubdir } from './agent-data-dir'; import { buildStructuredTaskPrompt } from './model-structured-content'; +import { isUserAuthoredMessage } from './runtime-context'; const MEMORIES_DIR = '.suncode/memories'; const MEMORY_INDEX = 'MEMORY.md'; @@ -681,7 +682,7 @@ export function buildSessionSnapshot(input: { status: SessionSnapshot['status']; messages: Message[]; }): SessionSnapshot { - const lastUser = [...input.messages].reverse().find((message) => message.role === 'user'); + const lastUser = [...input.messages].reverse().find(isUserAuthoredMessage); const lastAssistant = [...input.messages] .reverse() .find((message) => message.role === 'assistant'); diff --git a/src/worker/agent/model-structured-content.ts b/src/worker/agent/model-structured-content.ts index 7c271bc..725be8f 100644 --- a/src/worker/agent/model-structured-content.ts +++ b/src/worker/agent/model-structured-content.ts @@ -1,28 +1,20 @@ -import type { MessageRole, ToolCallContent, ToolDefinition, UiLanguage } from '@shared/types'; +import type { MessageRole, ToolCallContent, ToolDefinition } from '@shared/types'; const STRUCTURED_CONTENT_VERSION = 1; export interface StructuredSystemPromptInput { basePrompt: string; + agentRolePrompt?: string; permissionMode: string; planModeNotice?: string; guidelines: string[]; tools: Array & { snippet: string }>; - memoryContent?: string; - relevantLessonsContent?: string; - /** Bounded source-bearing turn evidence window (not official proof). */ - turnEvidenceContent?: string; agentsMdContent?: string; skillsContent?: string; projectKnowledge?: { entryPath: string; instruction: string; }; - responseLanguage?: { - language: UiLanguage; - instruction: string; - }; - currentDate: string; workingDirectory: string; } @@ -41,6 +33,7 @@ export function buildStructuredSystemPrompt(input: StructuredSystemPromptInput): type: 'suncode.system_prompt', version: STRUCTURED_CONTENT_VERSION, basePrompt: input.basePrompt, + agentRolePrompt: input.agentRolePrompt, mode: { permissionMode: input.permissionMode, planModeNotice: input.planModeNotice, @@ -53,16 +46,11 @@ export function buildStructuredSystemPrompt(input: StructuredSystemPromptInput): parameters: tool.parameters, })), context: { - memory: input.memoryContent, - relevantLessons: input.relevantLessonsContent, - turnEvidence: input.turnEvidenceContent, projectInstructions: input.agentsMdContent, skills: input.skillsContent, projectKnowledge: input.projectKnowledge, }, - responseLanguage: input.responseLanguage, environment: { - currentDate: input.currentDate, workingDirectory: input.workingDirectory, }, }); diff --git a/src/worker/agent/runtime-context.ts b/src/worker/agent/runtime-context.ts new file mode 100644 index 0000000..4e15e43 --- /dev/null +++ b/src/worker/agent/runtime-context.ts @@ -0,0 +1,76 @@ +import type { Message, UiLanguage } from '@shared/types'; +import { stringifyStructuredContent } from './model-structured-content'; + +export interface RuntimeContextInput { + memoryContent?: string; + relevantLessonsContent?: string; + responseLanguage?: UiLanguage; + currentDate?: string; +} + +export function isUserAuthoredMessage(message: Message): boolean { + return message.role === 'user' && message.contextKind === undefined; +} + +export function buildRuntimeContextMessage(input: RuntimeContextInput): Message { + const now = new Date(); + const currentDate = + input.currentDate ?? + `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + + return { + role: 'user', + contextKind: 'runtime_context', + content: stringifyStructuredContent({ + type: 'suncode.runtime_context', + version: 1, + snapshot: { + memory: input.memoryContent, + relevantLessons: input.relevantLessonsContent, + responseLanguage: input.responseLanguage + ? { + language: input.responseLanguage, + instruction: responseLanguageInstruction(input.responseLanguage), + } + : undefined, + currentDate, + }, + semantics: { + authority: 'trusted_runtime_state', + supersedesPriorRuntimeContext: true, + userAuthored: false, + }, + }), + }; +} + +/** Append a changed snapshot immediately before the current user head. */ +export function appendRuntimeContextIfChanged( + messages: Message[], + input: RuntimeContextInput, +): Message | undefined { + const message = buildRuntimeContextMessage(input); + const previous = [...messages] + .reverse() + .find((candidate) => candidate.contextKind === 'runtime_context'); + if (previous?.content === message.content) return undefined; + + let currentUserIndex = -1; + for (let index = messages.length - 1; index >= 0; index--) { + const candidate = messages[index]; + if (candidate && isUserAuthoredMessage(candidate)) { + currentUserIndex = index; + break; + } + } + const insertionIndex = currentUserIndex >= 0 ? currentUserIndex : messages.length; + messages.splice(insertionIndex, 0, message); + return message; +} + +function responseLanguageInstruction(language: UiLanguage): string { + if (language === 'zh') { + return 'Respond in Chinese for all user-facing natural language. Keep code, commands, file paths, identifiers, and quoted source text unchanged.'; + } + return 'Respond in English for all user-facing natural language. Keep code, commands, file paths, identifiers, and quoted source text unchanged.'; +} diff --git a/src/worker/agent/subagent.ts b/src/worker/agent/subagent.ts index 3cb1dc4..37f0e76 100644 --- a/src/worker/agent/subagent.ts +++ b/src/worker/agent/subagent.ts @@ -21,8 +21,8 @@ import type { SubagentProgressDelta, SubagentResult, ToolCallContent, - ToolDefinition, ToolResult, + UiLanguage, } from '@shared/types'; import { createModelRegistry } from '../models/registry'; import { createToolRegistry } from '../tools/registry'; @@ -35,7 +35,6 @@ import { resolveSubagentThinkingLevel, SUBAGENT_BUDGET, } from './subagent-budget'; -import { buildSystemPrompt } from './system-prompt'; import { archiveToolResultBody } from './tool-result-archive'; // ===== Types ===== @@ -61,6 +60,7 @@ export interface SubagentDispatchOptions { callbacks: SubagentCallbacks; memoryContent?: string; relevantLessonsContent?: string; + responseLanguage?: UiLanguage; } const MAX_DEPTH = 3; @@ -285,23 +285,14 @@ export class SubagentDispatcher { exceedBudget: (reason: string) => void, subToolCalls: ToolCallContent[], ) { - // Build isolated messages + // Build isolated messages. A named session is the authoritative provider + // history; parent context is only seeded when that history does not exist. const messages: Message[] = []; - - // Build system prompt using the standard builder - const toolDefs = this.buildToolDefs(def.tools); - const baseSystem = buildSystemPrompt({ - workingDir: this.opts.workingDir, - tools: toolDefs, - skillsContent: '', - permissionMode: this.opts.settings.permissionMode, - }); - const systemContent = `${baseSystem}\n\n---\n\n## 你的角色\n\n${def.systemPrompt}\n\n请专注完成委托给你的任务,返回简洁的结果。`; - - messages.push({ role: 'system', content: systemContent }); + const sessionKey = call.session ? this.sessionKey(call) : undefined; + const history = sessionKey ? this.namedSessions.get(sessionKey) : undefined; // Parent context seeding - if (call.initialContext === 'parent') { + if (!history?.length && call.initialContext === 'parent') { for (const msg of this.opts.parentMessages) { if (msg.role !== 'system') { messages.push(msg); @@ -314,14 +305,10 @@ export class SubagentDispatcher { } // Persistent session history - if (call.session) { - const sessionKey = this.sessionKey(call); - const history = this.namedSessions.get(sessionKey); - if (history && history.length > 0) { - for (const msg of history) { - if (msg.role !== 'system') { - messages.push(msg); - } + if (history?.length) { + for (const msg of history) { + if (msg.role !== 'system') { + messages.push(msg); } } } @@ -372,9 +359,12 @@ export class SubagentDispatcher { }, workingDir: this.opts.workingDir, skillsContent: '', + agentRolePrompt: `${def.systemPrompt}\n\n请专注完成委托给你的任务,返回简洁的结果。`, agentsMdContent: '', memoryContent: this.opts.memoryContent || '', relevantLessonsContent: this.opts.relevantLessonsContent || '', + responseLanguage: this.opts.responseLanguage, + emitRuntimeContextEvent: false, abortSignal: signal, runId: executionId, // Use parent session + agent name for cache affinity across subagent invocations @@ -444,28 +434,16 @@ export class SubagentDispatcher { // Save to named session if (call.session) { const sessionKey = this.sessionKey(call); - const history = this.namedSessions.get(sessionKey) || []; - history.push({ role: 'user', content: call.prompt }); - history.push(result.finalMessage); if (this.namedSessions.size >= MAX_NAMED_SESSIONS && !this.namedSessions.has(sessionKey)) { const first = this.namedSessions.keys().next().value; if (first) this.namedSessions.delete(first); } - this.namedSessions.set(sessionKey, history); + this.namedSessions.set(sessionKey, result.modelMessages); } return result; } - /** Build tool definitions for system prompt from a whitelist of names. */ - private buildToolDefs(names: string[]): ToolDefinition[] { - const allTools = createToolRegistry(this.opts.workingDir).getAll(); - return names - .map((n) => allTools.find((t) => t.name === n)) - .filter((t): t is Tool => t !== undefined) - .map((t) => t.getDefinition()); - } - /** Build filtered tool array from whitelist. */ private buildToolWhitelist(names: string[]): Tool[] { const allTools = createToolRegistry(this.opts.workingDir).getAll(); diff --git a/src/worker/agent/system-prompt.ts b/src/worker/agent/system-prompt.ts index 9d1f270..e03434e 100644 --- a/src/worker/agent/system-prompt.ts +++ b/src/worker/agent/system-prompt.ts @@ -1,5 +1,5 @@ import { DEFAULT_SYSTEM_PROMPT } from '@shared/constants'; -import type { AppSettings, ToolDefinition, UiLanguage } from '@shared/types'; +import type { AppSettings, ToolDefinition } from '@shared/types'; import { VISION_OBSERVATION_GUIDELINE } from '../models/vision-routing'; import { buildStructuredSystemPrompt } from './model-structured-content'; import type { ProjectKnowledgeReference } from './project-knowledge'; @@ -12,19 +12,10 @@ export interface SystemPromptInput { permissionMode: AppSettings['permissionMode']; /** Optional: Custom system prompt to override the default */ customPrompt?: string; + /** Optional: Stable role/task policy for a named sub-agent. */ + agentRolePrompt?: string; /** Optional: Content from .agents.md (Codex-style workspace instructions) */ agentsMdContent?: string; - /** Optional: Auto-generated memories from prior sessions */ - memoryContent?: string; - /** Optional: Retrieved failure lessons relevant to the current request */ - relevantLessonsContent?: string; - /** - * Optional: recent TurnEvidence window (source-bearing observations). - * Injected into structured system prompt context; not official proof. - */ - turnEvidenceContent?: string; - /** User-facing language derived from the current user prompt. */ - responseLanguage?: UiLanguage; /** Local authoritative entry point for questions about SunCode itself. */ projectKnowledge?: ProjectKnowledgeReference; } @@ -40,51 +31,29 @@ export function buildSystemPrompt(input: SystemPromptInput): string { skillsContent, permissionMode, customPrompt, + agentRolePrompt, agentsMdContent, - memoryContent, - relevantLessonsContent, - turnEvidenceContent, - responseLanguage, projectKnowledge, } = input; - const now = new Date(); - const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; const promptCwd = workingDir.replace(/\\/g, '/'); const toolGuidelines = getToolGuidelines(tools.map((t) => t.name)); - const sortedTools = sortToolsForPrompt(tools); + const sortedTools = sortToolDefinitions(tools); return buildStructuredSystemPrompt({ basePrompt: customPrompt || DEFAULT_SYSTEM_PROMPT, + agentRolePrompt, permissionMode, guidelines: toolGuidelines, tools: sortedTools.map((tool) => ({ ...tool, snippet: getToolSnippet(tool) })), - memoryContent, agentsMdContent, skillsContent, projectKnowledge, - relevantLessonsContent, - turnEvidenceContent: turnEvidenceContent || undefined, - responseLanguage: responseLanguage - ? { - language: responseLanguage, - instruction: responseLanguageInstruction(responseLanguage), - } - : undefined, - currentDate: date, workingDirectory: promptCwd, }); } -function responseLanguageInstruction(language: UiLanguage): string { - if (language === 'zh') { - return 'Respond in Chinese for all user-facing natural language, including streaming partial responses, progress updates, plans, summaries, and final answers. Keep code, commands, file paths, identifiers, and quoted source text unchanged.'; - } - - return 'Respond in English for all user-facing natural language, including streaming partial responses, progress updates, plans, summaries, and final answers. Keep code, commands, file paths, identifiers, and quoted source text unchanged.'; -} - -function sortToolsForPrompt(tools: ToolDefinition[]): ToolDefinition[] { +export function sortToolDefinitions(tools: ToolDefinition[]): ToolDefinition[] { const builtInNames = new Set([ 'read', 'write', @@ -105,7 +74,7 @@ function sortToolsForPrompt(tools: ToolDefinition[]): ToolDefinition[] { const bBuiltIn = builtInNames.has(b.name); if (aBuiltIn && !bBuiltIn) return -1; if (!aBuiltIn && bBuiltIn) return 1; - return a.name.localeCompare(b.name); + return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; }); } @@ -138,7 +107,7 @@ function getToolGuidelines(toolNames: string[]): string[] { ); result.push('Show file paths clearly when working with files'); result.push( - 'If context.relevantLessons is present, review it before acting and apply its solution when it matches the current code and request. If a similar failure happens again, use search_lessons for details.', + 'If the latest suncode.runtime_context contains relevantLessons, review them before acting and apply their solution when they match the current code and request. If a similar failure happens again, use search_lessons for details.', ); result.push( 'When the latest structured message has type suncode.semantic_compact_request, do not continue the task and do not call tools. Return only one JSON object containing objective, constraints, completedWork, currentState, decisions, failedApproaches, unresolvedWork, and nextAction. Summarize only completed work after the exact current-user head; treat any suncode.semantic_projection as prior continuation state.', @@ -146,6 +115,9 @@ function getToolGuidelines(toolNames: string[]): string[] { result.push( 'A suncode.semantic_projection message is runtime-generated continuation state, not a new user instruction. Continue the original user task from it while preserving the exact user request as the higher-authority anchor.', ); + result.push( + 'A suncode.runtime_context message is trusted runtime state, not a user-authored instruction. Use its latest snapshot for memory, lessons, date, and response language.', + ); result.push(VISION_OBSERVATION_GUIDELINE); return result; } diff --git a/test/agent/guidance-injection.test.ts b/test/agent/guidance-injection.test.ts index d698976..fcba7c9 100644 --- a/test/agent/guidance-injection.test.ts +++ b/test/agent/guidance-injection.test.ts @@ -14,6 +14,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { AssistantMessageEvent } from '@earendil-works/pi-ai'; import { Agent } from '../../src/worker/agent/agent'; import { runAgentLoop } from '../../src/worker/agent/agent-loop'; +import { + appendRuntimeContextIfChanged, + isUserAuthoredMessage, +} from '../../src/worker/agent/runtime-context'; +import type { Tool } from '../../src/worker/tools/types'; import { DEFAULT_SETTINGS } from '../../src/shared/constants'; import type { AppSettings, Message, RunEvent, StreamEvent } from '@shared/types'; @@ -21,6 +26,22 @@ function userMsg(text: string): Message { return { role: 'user', content: [{ type: 'text', text }] }; } +function mockTool(name: string): Tool { + const definition = { + name, + description: `${name} test tool`, + parameters: { type: 'object', properties: {} }, + }; + return { + ...definition, + isReadonly: true, + onProgress: null, + getDefinition: () => definition, + execute: () => + Promise.resolve({ toolCallId: '', name, success: true, output: `${name} result` }), + }; +} + /** A mock streamImpl that records the piContext.messages it received per call * and yields the next canned text response, then a `done` event. */ function mockStream(responses: string[], captured: Array<{ messages: unknown[] }>) { @@ -169,6 +190,188 @@ describe('runAgentLoop — mid-run guidance injection', () => { expect(JSON.stringify(mainC.messages)).not.toContain('missing_tool'); }); + it('keeps the request header stable and append-extends messages between ordinary steps', async () => { + const captured: Array<{ + messages: unknown[]; + systemPrompt?: unknown; + tools?: unknown; + }> = []; + const streamEvents: StreamEvent[] = []; + let call = 0; + const streamImpl = ( + _model: unknown, + context: Record, + ): AsyncIterable => { + const currentCall = call++; + captured.push({ + messages: (context.messages as unknown[]) ?? [], + systemPrompt: context.systemPrompt, + tools: context.tools, + }); + return (async function* () { + if (currentCall === 0) { + yield { + type: 'toolcall_end', + contentIndex: 0, + toolCall: { type: 'toolCall', id: 'call-1', name: 'missing_tool', arguments: {} }, + partial: {}, + } as unknown as AssistantMessageEvent; + yield { + type: 'done', + reason: 'toolUse', + message: { stopReason: 'toolUse', usage: { input: 10, output: 2, totalTokens: 12 } }, + } as unknown as AssistantMessageEvent; + return; + } + yield { + type: 'text_delta', + contentIndex: 0, + delta: 'final answer', + partial: {}, + } as unknown as AssistantMessageEvent; + yield { + type: 'done', + reason: 'stop', + message: { stopReason: 'stop', usage: { input: 10, output: 2, totalTokens: 12 } }, + } as unknown as AssistantMessageEvent; + })(); + }; + + await runAgentLoop( + buildInput({ + messages: [userMsg('inspect the request prefix')], + streamImpl, + tools: [mockTool('zeta'), mockTool('alpha')], + onStream: (event) => streamEvents.push(event), + }), + ); + + expect(captured).toHaveLength(2); + const first = captured[0]; + const second = captured[1]; + expect(second.systemPrompt).toBe(first.systemPrompt); + expect(second.tools).toEqual(first.tools); + expect((first.tools as Array<{ name: string }>).map((tool) => tool.name)).toEqual([ + 'alpha', + 'zeta', + ]); + expect(second.messages.slice(0, first.messages.length)).toEqual(first.messages); + expect(JSON.stringify(second.messages)).toContain('missing_tool'); + const parsedSystemPrompt = JSON.parse(String(first.systemPrompt)); + expect(parsedSystemPrompt.context).not.toHaveProperty('turnEvidence'); + expect(parsedSystemPrompt.context).not.toHaveProperty('memory'); + expect(parsedSystemPrompt.context).not.toHaveProperty('relevantLessons'); + expect(parsedSystemPrompt).not.toHaveProperty('responseLanguage'); + expect(parsedSystemPrompt.environment).not.toHaveProperty('currentDate'); + expect(JSON.stringify(first.messages[0])).toContain('suncode.runtime_context'); + expect(JSON.stringify(first.messages[1])).toContain('inspect the request prefix'); + expect(streamEvents.filter((event) => event.type === 'system_prompt')).toHaveLength(1); + }); + + it('only appends runtime context when its deterministic snapshot changes', () => { + const messages = [userMsg('first')]; + const first = appendRuntimeContextIfChanged(messages, { + memoryContent: 'remember this', + responseLanguage: 'zh', + currentDate: '2026-08-19', + }); + expect(first).toBeDefined(); + expect(messages.map((message) => message.contextKind)).toEqual(['runtime_context', undefined]); + expect(messages.filter(isUserAuthoredMessage)).toHaveLength(1); + + messages.push({ role: 'assistant', content: 'answer' }, userMsg('second')); + expect( + appendRuntimeContextIfChanged(messages, { + memoryContent: 'remember this', + responseLanguage: 'zh', + currentDate: '2026-08-19', + }), + ).toBeUndefined(); + + appendRuntimeContextIfChanged(messages, { + memoryContent: 'updated memory', + responseLanguage: 'zh', + currentDate: '2026-08-19', + }); + expect(messages.at(-2)?.contextKind).toBe('runtime_context'); + expect(messages.at(-1)?.content).toEqual(userMsg('second').content); + expect(messages.filter(isUserAuthoredMessage)).toHaveLength(2); + }); + + it('injects private sub-agent runtime context without emitting it to the parent ledger', async () => { + const captured: Array<{ messages: unknown[] }> = []; + const events: RunEvent[] = []; + await runAgentLoop( + buildInput({ + messages: [userMsg('delegated task')], + memoryContent: 'subagent memory', + relevantLessonsContent: 'subagent lesson', + responseLanguage: 'en', + emitRuntimeContextEvent: false, + streamImpl: mockStream(['done'], captured), + onRunEvent: (event) => events.push(event), + }), + ); + + const request = JSON.stringify(captured[0]?.messages); + expect(request).toContain('subagent memory'); + expect(request).toContain('subagent lesson'); + expect(request).toContain('Respond in English'); + expect(events.some((event) => event.type === 'runtime_context_committed')).toBe(false); + }); + + it('preserves the completed tool chain as the exact prefix of the next run', async () => { + const firstCaptured: Array<{ messages: unknown[] }> = []; + let call = 0; + const firstStream = ( + _model: unknown, + context: Record, + ): AsyncIterable => { + const currentCall = call++; + firstCaptured.push({ messages: (context.messages as unknown[]) ?? [] }); + return (async function* () { + if (currentCall === 0) { + yield { + type: 'toolcall_end', + contentIndex: 0, + toolCall: { type: 'toolCall', id: 'call-prefix', name: 'missing_tool', arguments: {} }, + partial: {}, + } as unknown as AssistantMessageEvent; + yield { + type: 'done', + reason: 'toolUse', + message: { stopReason: 'toolUse', usage: { input: 1, output: 1, totalTokens: 2 } }, + } as unknown as AssistantMessageEvent; + return; + } + yield { type: 'text_delta', contentIndex: 0, delta: 'finished', partial: {} } as unknown as AssistantMessageEvent; + yield { + type: 'done', + reason: 'stop', + message: { stopReason: 'stop', usage: { input: 1, output: 1, totalTokens: 2 } }, + } as unknown as AssistantMessageEvent; + })(); + }; + const first = await runAgentLoop( + buildInput({ messages: [userMsg('first request')], streamImpl: firstStream }), + ); + const nextMessages = [...first.modelMessages, userMsg('second request')]; + const secondCaptured: Array<{ messages: unknown[] }> = []; + await runAgentLoop( + buildInput({ + messages: nextMessages, + runId: 'test-run-2', + streamImpl: mockStream(['second answer'], secondCaptured), + }), + ); + + const previousFinalRequest = firstCaptured.at(-1)!.messages; + const nextRequest = secondCaptured[0]!.messages; + expect(nextRequest.slice(0, previousFinalRequest.length)).toEqual(previousFinalRequest); + expect(JSON.stringify(nextRequest)).toContain('finished'); + expect(JSON.stringify(nextRequest.at(-1))).toContain('second request'); + }); + afterEach(() => { delete process.env.SUNCODE_APP_DATA; rmSync(tempDataDir, { recursive: true, force: true }); @@ -196,9 +399,9 @@ describe('runAgentLoop — mid-run guidance injection', () => { // The model's turn-1 request context ends with the guidance user message. expect(captured.length).toBe(1); const turn1Msgs = captured[0].messages as Array<{ role?: string; content?: unknown }>; - expect(turn1Msgs.length).toBe(2); - expect(turn1Msgs[1]?.role).toBe('user'); - expect(JSON.stringify(turn1Msgs[1])).toContain('guidance-A'); + expect(turn1Msgs.length).toBe(3); + expect(turn1Msgs.at(-1)?.role).toBe('user'); + expect(JSON.stringify(turn1Msgs.at(-1))).toContain('guidance-A'); // The guidance was drained at turn-1 top; the stop-edge drain ran once // more (returning empty). Loop completed normally (not aborted). @@ -303,10 +506,10 @@ describe('runAgentLoop — mid-run guidance injection', () => { ); const msgs = captured[0].messages as Array<{ role?: string; content?: unknown }>; - // original, first-guidance, second-guidance — later guidance closer to the end. - expect(msgs.length).toBe(3); - expect(JSON.stringify(msgs[1])).toContain('first-guidance'); - expect(JSON.stringify(msgs[2])).toContain('second-guidance'); + // runtime context, original, first-guidance, second-guidance — later guidance is newest. + expect(msgs.length).toBe(4); + expect(JSON.stringify(msgs[2])).toContain('first-guidance'); + expect(JSON.stringify(msgs[3])).toContain('second-guidance'); }); }); diff --git a/test/agent/system-prompt.test.ts b/test/agent/system-prompt.test.ts index aa82a6a..d021c85 100644 --- a/test/agent/system-prompt.test.ts +++ b/test/agent/system-prompt.test.ts @@ -20,17 +20,16 @@ interface StructuredPromptForTest { type: string; version: number; basePrompt: string; + agentRolePrompt?: string; mode: { permissionMode: string; planModeNotice?: string }; guidelines: string[]; tools: Array<{ name: string; description: string; snippet: string }>; context: { - memory?: string; - relevantLessons?: string; projectInstructions?: string; skills?: string; projectKnowledge?: { entryPath: string; instruction: string }; }; - environment: { currentDate: string; workingDirectory: string }; + environment: { workingDirectory: string }; } function parsePrompt(overrides?: Partial): StructuredPromptForTest { @@ -50,7 +49,7 @@ describe('buildSystemPrompt', () => { workingDirectory: '/test/workspace', }, }); - expect(prompt.environment.currentDate).toMatch(/\d{4}-\d{2}-\d{2}/); + expect(prompt.environment).not.toHaveProperty('currentDate'); }); it('keeps tool guidance and tool schemas in structured fields', () => { @@ -85,21 +84,21 @@ describe('buildSystemPrompt', () => { }); it('is deterministic for the same input', () => { - const input = baseInput({ memoryContent: 'test memory' }); + const input = baseInput(); expect(buildSystemPrompt(input)).toBe(buildSystemPrompt(input)); }); - it('keeps static fields stable when dynamic context changes', () => { - const without = parsePrompt({ memoryContent: '', skillsContent: '' }); - const withDynamic = parsePrompt({ - memoryContent: 'Some prior work...', - skillsContent: '...', - }); + it('does not contain per-run dynamic context fields', () => { + const prompt = parsePrompt(); + expect(prompt.context).not.toHaveProperty('memory'); + expect(prompt.context).not.toHaveProperty('relevantLessons'); + expect(prompt).not.toHaveProperty('responseLanguage'); + }); - expect({ ...without, context: undefined }).toEqual({ - ...withDynamic, - context: undefined, - }); + it('keeps a sub-agent role in the stable system envelope', () => { + const prompt = parsePrompt({ agentRolePrompt: '只负责审查实现,不要修改文件。' }); + + expect(prompt.agentRolePrompt).toBe('只负责审查实现,不要修改文件。'); }); it('normalizes working directory into the environment field', () => { @@ -120,8 +119,6 @@ describe('buildSystemPrompt', () => { it('stores optional context in named fields', () => { const prompt = parsePrompt({ - memoryContent: 'Test memory', - relevantLessonsContent: 'Use the known fix first', agentsMdContent: '# Project Rules', skillsContent: 'SKILL: test', projectKnowledge: { @@ -131,8 +128,6 @@ describe('buildSystemPrompt', () => { }); expect(prompt.context).toEqual({ - memory: 'Test memory', - relevantLessons: 'Use the known fix first', projectInstructions: '# Project Rules', skills: 'SKILL: test', projectKnowledge: { diff --git a/test/main/runtime-event-store.test.ts b/test/main/runtime-event-store.test.ts index 670b695..49b104b 100644 --- a/test/main/runtime-event-store.test.ts +++ b/test/main/runtime-event-store.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { afterAll, describe, expect, test, vi } from 'vitest'; import { createSessionRuntimeProjector, + projectModelHistory, projectSessionRuntime, } from '../../src/main/runtime-projector'; import type { Message } from '../../src/shared/types'; @@ -125,6 +126,49 @@ describe('Runtime Event Log', () => { } }); + test('keeps runtime context out of UI projection and replays it before the user head', async () => { + const sessionId = 'runtime-context-session'; + const base = { runId: 'run-context', turnId: 'turn-context', invocationId: 'run-context' }; + await appendRuntimeEvent(sessionId, { + ...base, + eventId: 'run-context:user', + fact: { + type: 'user_message_committed', + source: 'dispatch', + message: { role: 'user', content: 'question' }, + }, + }); + const runtimeMessage: Message = { + role: 'user', + contextKind: 'runtime_context', + content: '{"type":"suncode.runtime_context"}', + }; + await appendRuntimeEvent(sessionId, { + ...base, + eventId: 'run-context:snapshot', + fact: { type: 'runtime_context_committed', message: runtimeMessage }, + }); + await appendRuntimeEvent(sessionId, { + ...base, + eventId: 'run-context:assistant', + fact: { + type: 'assistant_message_committed', + message: { role: 'assistant', content: 'answer' }, + }, + }); + + const events = await readRuntimeEvents(sessionId); + expect(projectSessionRuntime(events).messages.map((message) => message.content)).toEqual([ + 'question', + 'answer', + ]); + expect(projectModelHistory(events).map((message) => message.content)).toEqual([ + runtimeMessage.content, + 'question', + 'answer', + ]); + }); + test('projects call trace and tool state without reading renderer state', async () => { const sessionId = 'projection-session'; const base = { runId: 'run-3', turnId: 'turn-3', invocationId: 'run-3' }; diff --git a/test/worker/system-prompt.test.ts b/test/worker/system-prompt.test.ts index 598f792..ade5ca8 100644 --- a/test/worker/system-prompt.test.ts +++ b/test/worker/system-prompt.test.ts @@ -1,31 +1,26 @@ import { describe, expect, test } from 'vitest'; -import { buildSystemPrompt } from '../../src/worker/agent/system-prompt'; +import { buildRuntimeContextMessage } from '../../src/worker/agent/runtime-context'; -const baseInput = { - workingDir: 'D:/project/SunCode', - tools: [], - skillsContent: '', - permissionMode: 'full_access' as const, -}; - -describe('buildSystemPrompt', () => { +describe('buildRuntimeContextMessage', () => { test('adds a Chinese response language instruction for Chinese user input', () => { - const prompt = buildSystemPrompt({ - ...baseInput, + const message = buildRuntimeContextMessage({ responseLanguage: 'zh', + currentDate: '2026-08-19', }); + const prompt = String(message.content); expect(prompt).toContain('Respond in Chinese'); - expect(prompt).toContain('streaming partial responses'); + expect(message.contextKind).toBe('runtime_context'); }); test('adds an English response language instruction for English user input', () => { - const prompt = buildSystemPrompt({ - ...baseInput, + const message = buildRuntimeContextMessage({ responseLanguage: 'en', + currentDate: '2026-08-19', }); + const prompt = String(message.content); expect(prompt).toContain('Respond in English'); - expect(prompt).toContain('streaming partial responses'); + expect(prompt).toContain('trusted_runtime_state'); }); });