min-agent 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +146 -18
- package/dist/agent.js +293 -408
- package/dist/assistant-stream.js +11 -7
- package/dist/cli.js +403 -140
- package/dist/clipboard.js +59 -23
- package/dist/code-mode.js +3 -3
- package/dist/compaction.js +182 -81
- package/dist/config.js +186 -35
- package/dist/confirm.js +55 -6
- package/dist/context-window.js +67 -54
- package/dist/doom-loop.js +19 -12
- package/dist/http.js +119 -0
- package/dist/instructions.js +51 -33
- package/dist/logger.js +66 -0
- package/dist/markdown.js +3 -44
- package/dist/mcp.js +547 -100
- package/dist/memory.js +48 -6
- package/dist/output.js +36 -27
- package/dist/paste-handler.js +3 -3
- package/dist/plugins.js +33 -6
- package/dist/pricing.js +119 -0
- package/dist/provider.js +17 -15
- package/dist/serve.js +658 -369
- package/dist/sessions.js +151 -13
- package/dist/skills.js +466 -76
- package/dist/synthetic.js +7 -0
- package/dist/title-gen.js +2 -1
- package/dist/tool-display.js +173 -0
- package/dist/tool-output.js +54 -45
- package/dist/tools/apply_patch.js +191 -0
- package/dist/tools/backend.js +61 -0
- package/dist/tools/bash.js +147 -70
- package/dist/tools/code_search.js +6 -5
- package/dist/tools/edit.js +23 -7
- package/dist/tools/explore.js +80 -12
- package/dist/tools/glob.js +3 -3
- package/dist/tools/grep.js +146 -14
- package/dist/tools/index.js +7 -7
- package/dist/tools/question.js +4 -22
- package/dist/tools/read.js +71 -11
- package/dist/tools/task.js +33 -20
- package/dist/tools/todo.js +83 -73
- package/dist/tools/web_fetch.js +150 -46
- package/dist/tools/web_search.js +706 -28
- package/dist/tools/write.js +13 -7
- package/dist/tui/App.js +40 -6
- package/dist/tui/ConfirmBar.js +24 -3
- package/dist/tui/InputBar.js +390 -45
- package/dist/tui/MessageList.js +533 -20
- package/dist/tui/ModelPicker.js +108 -0
- package/dist/tui/QuestionBar.js +104 -0
- package/dist/tui/StatusBar.js +19 -11
- package/dist/tui/agent-runner.js +103 -0
- package/dist/tui/caret-pos.js +134 -0
- package/dist/tui/caret.js +69 -0
- package/dist/tui/diff-view.js +61 -0
- package/dist/tui/drag-state.js +44 -0
- package/dist/tui/index.js +153 -24
- package/dist/tui/input-history.js +44 -0
- package/dist/tui/layout.js +17 -0
- package/dist/tui/mouse.js +46 -0
- package/dist/tui/selection.js +134 -0
- package/dist/tui/slash-commands.js +90 -0
- package/dist/tui/slash-handler.js +370 -0
- package/dist/tui/text-width.js +91 -0
- package/dist/tui/theme.js +12 -0
- package/dist/tui/undo-stack.js +14 -0
- package/dist/tui/use-sgr-mouse.js +27 -0
- package/dist/tui-chat.js +111 -331
- package/dist/updater.js +57 -0
- package/docs/API.md +160 -14
- package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
- package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
- package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
- package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
- package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
- package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
- package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
- package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
- package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
- package/package.json +7 -8
|
@@ -0,0 +1,1595 @@
|
|
|
1
|
+
# 第三批:配置 + 工程 Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** 多 provider(破坏式 + 自动迁移)、sampling/budget 配置、日志系统、CI、/plan 计划模式、文档完善。
|
|
6
|
+
|
|
7
|
+
**Architecture:** config.ts 提供迁移与 activeProvider 解析(`migrateLegacyConfig` + `getActiveProvider`),provider.ts 按 active/覆盖名解析模型;agent.ts 的 RunOptions 承载 providerName/planMode,runOnceCore 统一处理采样回退与预算检查;logger.ts 为独立轻量模块(同步追加写)。
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript, AI SDK v6, bun:test
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md`
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 文件结构
|
|
16
|
+
|
|
17
|
+
| 文件 | 职责 |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `src/config.ts`(改) | providers[] + activeProvider、迁移、getActiveProvider、sampling/budget 字段、setup 重构 |
|
|
20
|
+
| `src/provider.ts`(改) | resolveModel 按 active/覆盖名、getProviderNames |
|
|
21
|
+
| `src/logger.ts`(新) | 日期滚动日志 |
|
|
22
|
+
| `tests/logger.test.ts`(新) | 日志测试 |
|
|
23
|
+
| `src/agent.ts`(改) | RunOptions.providerName/planMode、采样回退、预算检查、日志记录点 |
|
|
24
|
+
| `src/cli.ts`(改) | --provider、models、setup 提示、日志初始化 |
|
|
25
|
+
| `src/tui-chat.ts`(改) | /provider、/budget、/plan、onRunFinish 预算提示 |
|
|
26
|
+
| `src/tui/slash-commands.ts`(改) | 菜单新命令 |
|
|
27
|
+
| `src/serve.ts`(改) | getActiveProvider 适配、body.provider、done 事件 budget_exceeded |
|
|
28
|
+
| `src/context-window.ts`(改) | getActiveProvider 适配 |
|
|
29
|
+
| `src/compaction.ts`(改) | getActiveProvider 适配 |
|
|
30
|
+
| `src/output.ts`(改) | getActiveProvider 适配 |
|
|
31
|
+
| `src/tui/ModelPicker.tsx`(改) | getActiveProvider 适配 |
|
|
32
|
+
| `.github/workflows/ci.yml`(新) | CI |
|
|
33
|
+
| `tests/config.test.ts`(改) | 迁移测试 |
|
|
34
|
+
| `tests/provider.test.ts`(改) | resolveModel 测试 |
|
|
35
|
+
| `tests/serve.test.ts`(改) | provider/budget_exceeded 测试 |
|
|
36
|
+
| `docs/API.md`、`README.md`(改) | 文档同步 |
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Task 1: config.ts — 多 provider 结构 + 迁移 + getActiveProvider
|
|
41
|
+
|
|
42
|
+
**Files:**
|
|
43
|
+
- Modify: `src/config.ts`
|
|
44
|
+
- Test: `tests/config.test.ts`
|
|
45
|
+
|
|
46
|
+
- [ ] **Step 1: 写失败测试**
|
|
47
|
+
|
|
48
|
+
在 `tests/config.test.ts` 末尾追加:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { getActiveProvider, migrateLegacyConfig, loadConfig } from "../src/config.js"
|
|
52
|
+
|
|
53
|
+
test("migrateLegacyConfig converts the legacy single provider", () => {
|
|
54
|
+
const legacy = { provider: { baseURL: "https://x", apiKey: "k", defaultModel: "m1" } }
|
|
55
|
+
const { config, migrated } = migrateLegacyConfig(legacy as any)
|
|
56
|
+
expect(migrated).toBe(true)
|
|
57
|
+
expect(config.providers).toEqual([{ name: "default", baseURL: "https://x", apiKey: "k", defaultModel: "m1" }])
|
|
58
|
+
expect(config.activeProvider).toBe("default")
|
|
59
|
+
expect((config as any).provider).toBeUndefined()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test("migrateLegacyConfig is a no-op for the new format", () => {
|
|
63
|
+
const fresh = { providers: [{ name: "a", baseURL: "https://x", apiKey: "k" }], activeProvider: "a" }
|
|
64
|
+
const { config, migrated } = migrateLegacyConfig(fresh)
|
|
65
|
+
expect(migrated).toBe(false)
|
|
66
|
+
expect(config).toBe(fresh)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test("getActiveProvider resolves the active name", () => {
|
|
70
|
+
const cfg = {
|
|
71
|
+
providers: [
|
|
72
|
+
{ name: "a", baseURL: "u1", apiKey: "k1" },
|
|
73
|
+
{ name: "b", baseURL: "u2", apiKey: "k2" },
|
|
74
|
+
],
|
|
75
|
+
activeProvider: "b",
|
|
76
|
+
}
|
|
77
|
+
expect(getActiveProvider(cfg)?.name).toBe("b")
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test("getActiveProvider falls back to the first provider", () => {
|
|
81
|
+
const cfg = {
|
|
82
|
+
providers: [{ name: "a", baseURL: "u1", apiKey: "k1" }],
|
|
83
|
+
}
|
|
84
|
+
expect(getActiveProvider(cfg)?.name).toBe("a")
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test("getActiveProvider returns undefined without providers", () => {
|
|
88
|
+
expect(getActiveProvider({})).toBeUndefined()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test("loadConfig migrates and persists a legacy config file", () => {
|
|
92
|
+
const base = makeTemp()
|
|
93
|
+
const file = path.join(base, "config.json")
|
|
94
|
+
writeFileSync(file, JSON.stringify({ provider: { baseURL: "https://x", apiKey: "k" } }))
|
|
95
|
+
const oldDir = process.env.MIN_AGENT_CONFIG_DIR
|
|
96
|
+
process.env.MIN_AGENT_CONFIG_DIR = base
|
|
97
|
+
try {
|
|
98
|
+
const config = loadConfig()
|
|
99
|
+
expect(config.providers?.[0]?.name).toBe("default")
|
|
100
|
+
expect(config.activeProvider).toBe("default")
|
|
101
|
+
const onDisk = JSON.parse(readFileSync(file, "utf-8"))
|
|
102
|
+
expect(onDisk.providers[0].name).toBe("default")
|
|
103
|
+
expect(onDisk.provider).toBeUndefined()
|
|
104
|
+
} finally {
|
|
105
|
+
if (oldDir) process.env.MIN_AGENT_CONFIG_DIR = oldDir
|
|
106
|
+
else delete process.env.MIN_AGENT_CONFIG_DIR
|
|
107
|
+
rmSync(base, { recursive: true, force: true })
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
(`readFileSync` 需补 import;`makeTemp` 已存在)
|
|
113
|
+
|
|
114
|
+
- [ ] **Step 2: 运行测试确认失败**
|
|
115
|
+
|
|
116
|
+
Run: `bun test tests/config.test.ts`
|
|
117
|
+
Expected: 新用例 FAIL(migrateLegacyConfig/getActiveProvider 不存在)
|
|
118
|
+
|
|
119
|
+
- [ ] **Step 3: 修改 config.ts**
|
|
120
|
+
|
|
121
|
+
**a)** `ProviderConfig` 接口加 name、`AppConfig` 重构:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
export interface ProviderConfig {
|
|
125
|
+
name?: string
|
|
126
|
+
type?: "openai-compatible" | "openai" | "ollama"
|
|
127
|
+
baseURL: string
|
|
128
|
+
apiKey: string
|
|
129
|
+
defaultModel?: string
|
|
130
|
+
/** Context window size in tokens (default: 128000) */
|
|
131
|
+
contextWindow?: number
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface AppConfig {
|
|
135
|
+
providers?: ProviderConfig[]
|
|
136
|
+
activeProvider?: string
|
|
137
|
+
instructions?: string[]
|
|
138
|
+
disabledSkills?: string[]
|
|
139
|
+
/** Permission mode: "allow-all" auto-approves everything, "ask" prompts for dangerous ops (default) */
|
|
140
|
+
permission?: "allow-all" | "ask"
|
|
141
|
+
/** Compaction settings */
|
|
142
|
+
compaction?: {
|
|
143
|
+
/** Model to use for summarization (defaults to main model). Use a cheap fast model like "gemini-2.0-flash" */
|
|
144
|
+
model?: string
|
|
145
|
+
/** Auto-continue after compaction (default: true) */
|
|
146
|
+
autoContinue?: boolean
|
|
147
|
+
}
|
|
148
|
+
/** SearXNG-compatible web search instance (default: public instance) */
|
|
149
|
+
webSearchURL?: string
|
|
150
|
+
/** Firecrawl-compatible web fetch instance (default: public instance) */
|
|
151
|
+
webFetchURL?: string
|
|
152
|
+
/** Per-model price overrides (USD per 1M tokens), used for cost estimation */
|
|
153
|
+
pricing?: Record<string, { inputPerMillion: number; outputPerMillion: number }>
|
|
154
|
+
/** Sampling parameters applied when the request does not override them */
|
|
155
|
+
sampling?: { temperature?: number; maxTokens?: number; topP?: number }
|
|
156
|
+
/** Cost budget in USD; the agent stops suggesting work beyond it */
|
|
157
|
+
budget?: { maxCostUSD?: number }
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**b)** 添加迁移与解析函数(`loadConfig` 之后):
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
export function migrateLegacyConfig(config: AppConfig): { config: AppConfig; migrated: boolean } {
|
|
165
|
+
const legacy = (config as any).provider as ProviderConfig | undefined
|
|
166
|
+
if (!legacy) return { config, migrated: false }
|
|
167
|
+
const { provider, ...rest } = config as any
|
|
168
|
+
return {
|
|
169
|
+
config: { ...rest, providers: [{ name: "default", ...provider }], activeProvider: "default" },
|
|
170
|
+
migrated: true,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function getActiveProvider(config: AppConfig): ProviderConfig | undefined {
|
|
175
|
+
if (!config.providers || config.providers.length === 0) return undefined
|
|
176
|
+
if (config.activeProvider) {
|
|
177
|
+
const found = config.providers.find((p) => p.name === config.activeProvider)
|
|
178
|
+
if (found) return found
|
|
179
|
+
}
|
|
180
|
+
return config.providers[0]
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**c)** `loadConfig` 集成迁移:
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
export function loadConfig(): AppConfig {
|
|
188
|
+
const raw = loadConfigFile(path.join(getConfigDir(), CONFIG_FILE_NAME))
|
|
189
|
+
const { config, migrated } = migrateLegacyConfig(raw)
|
|
190
|
+
if (migrated) saveConfig(config)
|
|
191
|
+
return config
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
**d)** `isConfigured` 改:
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
export function isConfigured(): boolean {
|
|
199
|
+
const config = loadConfig()
|
|
200
|
+
const provider = getActiveProvider(config)
|
|
201
|
+
return !!(provider?.baseURL && provider?.apiKey)
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**e)** `runSetup` 重构(本任务先做最小可用版本,交互式多 provider 管理在 Task 3):
|
|
206
|
+
|
|
207
|
+
本任务先把 `runSetup` 的结尾保存改为 providers 结构(Task 3 再重构交互流程):
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
const providers = config.providers ?? []
|
|
211
|
+
const existing = getActiveProvider(config)
|
|
212
|
+
...
|
|
213
|
+
// 现有流程收集 typeChoice/baseURL/apiKey/defaultModel 后:
|
|
214
|
+
const providerEntry: ProviderConfig = { name: existing?.name ?? "default", type: providerType, baseURL, apiKey, defaultModel }
|
|
215
|
+
if (providers.length === 0) {
|
|
216
|
+
config.providers = [providerEntry]
|
|
217
|
+
config.activeProvider = "default"
|
|
218
|
+
} else {
|
|
219
|
+
const idx = providers.findIndex((p) => p.name === (existing?.name ?? "default"))
|
|
220
|
+
if (idx >= 0) providers[idx] = providerEntry
|
|
221
|
+
else providers.push(providerEntry)
|
|
222
|
+
config.providers = providers
|
|
223
|
+
}
|
|
224
|
+
saveConfig(config)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
注意:本任务中 setup 仍提示 `config.provider = {...}` 处需同步修改(消除编译错误)。Task 3 会完整重构 setup 交互。
|
|
228
|
+
|
|
229
|
+
- [ ] **Step 4: 运行测试确认通过**
|
|
230
|
+
|
|
231
|
+
Run: `bun test tests/config.test.ts`
|
|
232
|
+
Expected: PASS(原有 + 6 个新用例)
|
|
233
|
+
|
|
234
|
+
- [ ] **Step 5: 提交**
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
git add src/config.ts tests/config.test.ts
|
|
238
|
+
git commit -m "feat: 多 provider 配置结构与自动迁移"
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Task 2: 全代码库 provider 引用适配 + provider.ts 改造
|
|
244
|
+
|
|
245
|
+
**Files:**
|
|
246
|
+
- Modify: `src/provider.ts`
|
|
247
|
+
- Modify: `src/context-window.ts`
|
|
248
|
+
- Modify: `src/compaction.ts`
|
|
249
|
+
- Modify: `src/output.ts`
|
|
250
|
+
- Modify: `src/tui/ModelPicker.tsx`
|
|
251
|
+
- Test: `tests/provider.test.ts`
|
|
252
|
+
|
|
253
|
+
- [ ] **Step 1: 写失败测试**
|
|
254
|
+
|
|
255
|
+
在 `tests/provider.test.ts` 末尾追加:
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
import { getProviderNames, resolveModelForProvider, resolveModel } from "../src/provider.js"
|
|
259
|
+
|
|
260
|
+
test("getProviderNames returns configured names", () => {
|
|
261
|
+
const config = loadConfig()
|
|
262
|
+
const names = getProviderNames(config)
|
|
263
|
+
expect(Array.isArray(names)).toBe(true)
|
|
264
|
+
})
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
(`loadConfig` 需 import;如 provider.test.ts 已有隔离 setup,跟随现有模式。若无,追加一个 MIN_AGENT_CONFIG_DIR 隔离的测试块,先写 config.json 含两个 provider,断言 getProviderNames 返回两个名字、resolveModel 用 activeProvider 的 baseURL 创建模型、resolveModel("m", "b") 用 provider b)
|
|
268
|
+
|
|
269
|
+
- [ ] **Step 2: 运行测试确认失败**
|
|
270
|
+
|
|
271
|
+
Run: `bun test tests/provider.test.ts`
|
|
272
|
+
Expected: 新用例 FAIL(编译错误或函数不存在)
|
|
273
|
+
|
|
274
|
+
- [ ] **Step 3: 修改 provider.ts**
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
import { createOpenAI } from "@ai-sdk/openai"
|
|
278
|
+
import type { LanguageModel } from "ai"
|
|
279
|
+
import { loadConfig, getActiveProvider, type ProviderConfig } from "./config.js"
|
|
280
|
+
|
|
281
|
+
export function resolveModelForProvider(provider: ProviderConfig, modelId?: string): LanguageModel {
|
|
282
|
+
if (!provider.baseURL || !provider.apiKey) {
|
|
283
|
+
throw new Error("Not configured. Run: min-agent setup")
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const id = modelId ?? provider.defaultModel
|
|
287
|
+
if (!id) {
|
|
288
|
+
throw new Error("No model specified. Run: min-agent setup")
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
switch (provider.type ?? "openai-compatible") {
|
|
292
|
+
case "openai": {
|
|
293
|
+
const client = createOpenAI({ apiKey: provider.apiKey })
|
|
294
|
+
return client.chat(id) as unknown as LanguageModel
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
case "ollama": {
|
|
298
|
+
const client = createOpenAI({
|
|
299
|
+
baseURL: normalizeOllamaBaseURL(provider.baseURL),
|
|
300
|
+
apiKey: provider.apiKey || "ollama",
|
|
301
|
+
})
|
|
302
|
+
return client.chat(id) as unknown as LanguageModel
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
case "openai-compatible": {
|
|
306
|
+
const client = createOpenAI({
|
|
307
|
+
baseURL: provider.baseURL,
|
|
308
|
+
apiKey: provider.apiKey,
|
|
309
|
+
})
|
|
310
|
+
return client.chat(id) as unknown as LanguageModel
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
default:
|
|
314
|
+
throw new Error(`Unknown provider type: ${provider.type}`)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function resolveModel(modelId?: string, providerName?: string): LanguageModel {
|
|
319
|
+
const config = loadConfig()
|
|
320
|
+
const provider = providerName
|
|
321
|
+
? config.providers?.find((p) => p.name === providerName)
|
|
322
|
+
: getActiveProvider(config)
|
|
323
|
+
if (!provider) {
|
|
324
|
+
throw new Error(providerName ? `Provider "${providerName}" not found` : "Not configured. Run: min-agent setup")
|
|
325
|
+
}
|
|
326
|
+
return resolveModelForProvider(provider, modelId)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function getProviderNames(config: ReturnType<typeof loadConfig>): string[] {
|
|
330
|
+
return (config.providers ?? []).map((p) => p.name ?? "").filter(Boolean)
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
(`normalizeOllamaBaseURL` 需 import from config.js:`import { loadConfig, getActiveProvider, normalizeOllamaBaseURL, type ProviderConfig } from "./config.js"`)
|
|
335
|
+
|
|
336
|
+
- [ ] **Step 4: 适配其余引用点**
|
|
337
|
+
|
|
338
|
+
**context-window.ts**(detectContextWindow 与 getContextWindow):
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
import { getConfigDir, loadConfig, getActiveProvider, type AppConfig } from "./config.js"
|
|
342
|
+
|
|
343
|
+
async function detectContextWindow(config: AppConfig, id: string): Promise<number> {
|
|
344
|
+
const provider = getActiveProvider(config)
|
|
345
|
+
const baseURL = provider?.baseURL ?? ""
|
|
346
|
+
const apiKey = provider?.apiKey ?? ""
|
|
347
|
+
...
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export async function getContextWindow(modelId?: string): Promise<number> {
|
|
351
|
+
const config = loadConfig()
|
|
352
|
+
const provider = getActiveProvider(config)
|
|
353
|
+
// 1. Explicit user config
|
|
354
|
+
if (provider?.contextWindow) return provider.contextWindow
|
|
355
|
+
const id = modelId ?? provider?.defaultModel
|
|
356
|
+
...
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
**compaction.ts**(约 175-177 行):
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
const provider = getActiveProvider(cfg)
|
|
364
|
+
if (provider?.contextWindow) return provider.contextWindow
|
|
365
|
+
return await getContextWindow(provider?.defaultModel)
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
(compaction.ts import 加 getActiveProvider)
|
|
369
|
+
|
|
370
|
+
**output.ts**(约 18 行):
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
const model = modelId ?? getActiveProvider(loadConfig())?.defaultModel ?? "unknown"
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
(output.ts import 加 getActiveProvider;确认 output.ts 现有 import)
|
|
377
|
+
|
|
378
|
+
**tui/ModelPicker.tsx**(约 36 行):
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
const provider = getActiveProvider(cfg)
|
|
382
|
+
if (provider?.baseURL && provider?.apiKey) {
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
(ModelPicker.tsx import 加 getActiveProvider)
|
|
386
|
+
|
|
387
|
+
- [ ] **Step 5: 运行测试确认通过**
|
|
388
|
+
|
|
389
|
+
Run: `bun test` 全量与 `bun run typecheck`(仓库根)
|
|
390
|
+
Expected: 全部通过,无类型错误
|
|
391
|
+
|
|
392
|
+
- [ ] **Step 6: 提交**
|
|
393
|
+
|
|
394
|
+
```bash
|
|
395
|
+
git add src/provider.ts src/context-window.ts src/compaction.ts src/output.ts src/tui/ModelPicker.tsx tests/provider.test.ts
|
|
396
|
+
git commit -m "feat: 模型解析按 activeProvider 与覆盖名"
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
401
|
+
## Task 3: cli.ts — --provider、models、setup 交互重构
|
|
402
|
+
|
|
403
|
+
**Files:**
|
|
404
|
+
- Modify: `src/cli.ts`
|
|
405
|
+
- Modify: `src/config.ts`(runSetup)
|
|
406
|
+
|
|
407
|
+
- [ ] **Step 1: parseFlags 加 --provider**
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
interface ParsedFlags {
|
|
411
|
+
model?: string
|
|
412
|
+
provider?: string
|
|
413
|
+
resume?: string
|
|
414
|
+
images: string[]
|
|
415
|
+
port?: number
|
|
416
|
+
host?: string
|
|
417
|
+
positionals: string[]
|
|
418
|
+
}
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
解析循环加:
|
|
422
|
+
|
|
423
|
+
```ts
|
|
424
|
+
} else if (a === "--provider" && argv[i + 1]) {
|
|
425
|
+
provider = take()
|
|
426
|
+
}
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
返回对象加 `provider`。`chat` 分支解构加 `provider: providerOverride`,传给 runAgent/runTui。
|
|
430
|
+
|
|
431
|
+
**runAgent**(src/agent.ts)加 providerName 参数:
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
export async function runAgent(message: string, modelId?: string, imagePaths?: string[], providerName?: string) {
|
|
435
|
+
...
|
|
436
|
+
await runOnce(messages, instructions, modelId, undefined, undefined, tracker, undefined, providerName)
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
等等——runOnce 签名已定(Task 7 批 2):`runOnce(messages, instructions, modelId, abortSignal, callbacks, tracker, options)`。providerName 放 options:`options = { providerName }`。runOnceCore 里 `resolveModel(modelId, options?.providerName)`。
|
|
440
|
+
|
|
441
|
+
**修改 runOnceCore**:
|
|
442
|
+
|
|
443
|
+
```ts
|
|
444
|
+
const model = resolveModel(modelId, options?.providerName)
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
**修改 runOnce/runOnceWithSystem** 的 RunOptions(agent.ts):
|
|
448
|
+
|
|
449
|
+
```ts
|
|
450
|
+
export interface RunOptions {
|
|
451
|
+
temperature?: number
|
|
452
|
+
maxTokens?: number
|
|
453
|
+
topP?: number
|
|
454
|
+
providerName?: string
|
|
455
|
+
planMode?: boolean
|
|
456
|
+
}
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
(planMode 字段本任务先加上,Task 8 使用)
|
|
460
|
+
|
|
461
|
+
**runTui**(src/tui-chat.ts)TuiOptions 加 `providerName?: string`,`runTui` 内 `let currentProvider = opts.providerName`,executeAgent 的 runOnce 调用 options 传 `{ providerName: currentProvider }`(与后续 planMode 合并):
|
|
462
|
+
|
|
463
|
+
```ts
|
|
464
|
+
const runOptions = { providerName: currentProvider }
|
|
465
|
+
if (mode === "code" && codeSystemPrompt) {
|
|
466
|
+
await runOnceWithSystem(messages, codeSystemPrompt, currentModel, abortController?.signal, callbacks, tracker, runOptions)
|
|
467
|
+
} else {
|
|
468
|
+
await runOnce(messages, instructions, currentModel, abortController?.signal, callbacks, tracker, runOptions)
|
|
469
|
+
}
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
**cli.ts chat/code 分支**:
|
|
473
|
+
|
|
474
|
+
```ts
|
|
475
|
+
const { model: modelOverride, provider: providerOverride, resume: resumeId, images, positionals } = parseFlags(args.slice(1))
|
|
476
|
+
...
|
|
477
|
+
if (!message) {
|
|
478
|
+
const { runTui } = await import("./tui-chat.js")
|
|
479
|
+
await runTui({ modelId: modelOverride, providerName: providerOverride, resumeSessionId: resumeId, mode: "chat", images })
|
|
480
|
+
} else {
|
|
481
|
+
await runAgent(message, modelOverride, images.length > 0 ? images : undefined, providerOverride)
|
|
482
|
+
}
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
code 分支同样透传 providerName。
|
|
486
|
+
|
|
487
|
+
- [ ] **Step 2: models 命令适配**
|
|
488
|
+
|
|
489
|
+
```ts
|
|
490
|
+
case "models": {
|
|
491
|
+
const config = loadConfig()
|
|
492
|
+
const provider = getActiveProvider(config)
|
|
493
|
+
if (!provider?.baseURL || !provider?.apiKey) {
|
|
494
|
+
console.error("Not configured. Run: min-agent setup")
|
|
495
|
+
process.exit(1)
|
|
496
|
+
}
|
|
497
|
+
console.log(`Fetching models (${provider.name ?? "default"})...`)
|
|
498
|
+
const models = await fetchModels(provider.baseURL, provider.apiKey)
|
|
499
|
+
if (models.length === 0) {
|
|
500
|
+
console.log("No models found or unable to fetch model list.")
|
|
501
|
+
} else {
|
|
502
|
+
console.log(`\nAvailable models (${models.length}):`)
|
|
503
|
+
for (const m of models) {
|
|
504
|
+
const marker = m === provider.defaultModel ? " ← default" : ""
|
|
505
|
+
console.log(` ${m}${marker}`)
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
break
|
|
509
|
+
}
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
(cli.ts import 加 getActiveProvider)
|
|
513
|
+
|
|
514
|
+
- [ ] **Step 3: runSetup 完整重构(src/config.ts)**
|
|
515
|
+
|
|
516
|
+
将 `runSetup` 整体替换为:
|
|
517
|
+
|
|
518
|
+
```ts
|
|
519
|
+
export async function runSetup(): Promise<void> {
|
|
520
|
+
const config = loadConfig()
|
|
521
|
+
const providers = config.providers ?? []
|
|
522
|
+
const current = getActiveProvider(config)
|
|
523
|
+
|
|
524
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
525
|
+
|
|
526
|
+
console.log("\n🔧 min-agent 配置\n")
|
|
527
|
+
|
|
528
|
+
if (providers.length > 0) {
|
|
529
|
+
console.log("已配置的 Provider:")
|
|
530
|
+
providers.forEach((p, i) => {
|
|
531
|
+
const marker = p.name === config.activeProvider ? " ← 当前" : ""
|
|
532
|
+
console.log(` ${i + 1}. ${p.name ?? "(未命名)"} ${p.baseURL} (${p.defaultModel ?? "未设置模型"})${marker}`)
|
|
533
|
+
})
|
|
534
|
+
console.log()
|
|
535
|
+
console.log("选项:")
|
|
536
|
+
console.log(" 1. 添加新 Provider")
|
|
537
|
+
console.log(" 2. 切换当前 Provider")
|
|
538
|
+
console.log(" 3. 修改当前 Provider")
|
|
539
|
+
console.log(" 4. 完成")
|
|
540
|
+
const choice = await ask(rl, "选择 (1/2/3/4)", "4")
|
|
541
|
+
|
|
542
|
+
if (choice === "1") {
|
|
543
|
+
const provider = await collectProvider(rl, undefined)
|
|
544
|
+
provider.name = (await ask(rl, "Provider 名称", `provider-${providers.length + 1}`)).trim() || `provider-${providers.length + 1}`
|
|
545
|
+
providers.push(provider)
|
|
546
|
+
config.activeProvider = provider.name
|
|
547
|
+
config.providers = providers
|
|
548
|
+
saveConfig(config)
|
|
549
|
+
console.log(`\n✓ Provider "${provider.name}" 已添加并设为当前`)
|
|
550
|
+
} else if (choice === "2") {
|
|
551
|
+
const sel = await ask(rl, "输入序号选择当前 Provider")
|
|
552
|
+
const idx = parseInt(sel, 10) - 1
|
|
553
|
+
const target = providers[idx]
|
|
554
|
+
if (!target?.name) {
|
|
555
|
+
console.error("无效序号")
|
|
556
|
+
} else {
|
|
557
|
+
config.activeProvider = target.name
|
|
558
|
+
saveConfig(config)
|
|
559
|
+
console.log(`\n✓ 当前 Provider 已切换为: ${target.name}`)
|
|
560
|
+
}
|
|
561
|
+
} else if (choice === "3") {
|
|
562
|
+
const name = current?.name ?? providers[0]?.name
|
|
563
|
+
if (!name) { console.error("无可用 Provider"); rl.close(); return }
|
|
564
|
+
const idx = providers.findIndex((p) => p.name === name)
|
|
565
|
+
providers[idx] = await collectProvider(rl, providers[idx])
|
|
566
|
+
config.providers = providers
|
|
567
|
+
saveConfig(config)
|
|
568
|
+
console.log(`\n✓ Provider "${name}" 已更新`)
|
|
569
|
+
} else {
|
|
570
|
+
console.log("\n配置未变更")
|
|
571
|
+
}
|
|
572
|
+
rl.close()
|
|
573
|
+
return
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// 首次配置
|
|
577
|
+
const provider = await collectProvider(rl, undefined)
|
|
578
|
+
provider.name = "default"
|
|
579
|
+
config.providers = [provider]
|
|
580
|
+
config.activeProvider = "default"
|
|
581
|
+
saveConfig(config)
|
|
582
|
+
rl.close()
|
|
583
|
+
console.log(`\n✓ 配置已保存到 ${path.join(getConfigDir(), CONFIG_FILE_NAME)}`)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function collectProvider(rl: readline.Interface, existing?: ProviderConfig): Promise<ProviderConfig> {
|
|
587
|
+
console.log("Provider 类型:")
|
|
588
|
+
console.log(" 1. openai-compatible (默认,兼容 OpenAI API 的任意服务)")
|
|
589
|
+
console.log(" 2. openai (OpenAI 官方)")
|
|
590
|
+
console.log(" 3. ollama (本地 Ollama)")
|
|
591
|
+
console.log()
|
|
592
|
+
|
|
593
|
+
const typeChoice = await ask(rl, "选择 Provider (1/2/3)", existing?.type === "ollama" ? "3" : existing?.type === "openai" ? "2" : "1")
|
|
594
|
+
const providerType = typeChoice === "3" ? "ollama" as const
|
|
595
|
+
: typeChoice === "2" ? "openai" as const
|
|
596
|
+
: "openai-compatible" as const
|
|
597
|
+
|
|
598
|
+
let baseURL: string
|
|
599
|
+
let apiKey: string
|
|
600
|
+
|
|
601
|
+
if (providerType === "ollama") {
|
|
602
|
+
baseURL = await ask(rl, "Ollama API URL", existing?.baseURL || "http://localhost:11434/v1")
|
|
603
|
+
baseURL = normalizeOllamaBaseURL(baseURL)
|
|
604
|
+
apiKey = "ollama"
|
|
605
|
+
} else if (providerType === "openai") {
|
|
606
|
+
baseURL = "https://api.openai.com/v1"
|
|
607
|
+
apiKey = await ask(rl, "OpenAI API Key", existing?.apiKey)
|
|
608
|
+
} else {
|
|
609
|
+
baseURL = await ask(rl, "API Base URL", existing?.baseURL || "https://api.openai.com/v1")
|
|
610
|
+
apiKey = await ask(rl, "API Key", existing?.apiKey)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (!baseURL || (!apiKey && providerType !== "ollama")) {
|
|
614
|
+
console.error("URL 和 Key 不能为空")
|
|
615
|
+
process.exit(1)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
console.log("\n正在获取模型列表...")
|
|
619
|
+
const models = await fetchModels(baseURL, apiKey)
|
|
620
|
+
|
|
621
|
+
let defaultModel = existing?.defaultModel ?? ""
|
|
622
|
+
if (models.length > 0) {
|
|
623
|
+
console.log(`\n可用模型 (${models.length}):`)
|
|
624
|
+
models.forEach((m, i) => {
|
|
625
|
+
const marker = m === defaultModel ? " ← 当前默认" : ""
|
|
626
|
+
console.log(` ${i + 1}. ${m}${marker}`)
|
|
627
|
+
})
|
|
628
|
+
console.log()
|
|
629
|
+
const choice = await ask(rl, "选择默认模型 (输入序号或模型名)", defaultModel)
|
|
630
|
+
const idx = parseInt(choice, 10) - 1
|
|
631
|
+
if (idx >= 0 && idx < models.length) defaultModel = models[idx]!
|
|
632
|
+
else if (choice) defaultModel = choice
|
|
633
|
+
} else {
|
|
634
|
+
console.log(" ⚠ 无法获取模型列表,请手动输入模型名")
|
|
635
|
+
const hint = providerType === "ollama" ? "llama3" : providerType === "openai" ? "gpt-4o" : ""
|
|
636
|
+
defaultModel = await ask(rl, "默认模型", defaultModel || hint)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return { type: providerType, baseURL, apiKey, defaultModel }
|
|
640
|
+
}
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
- [ ] **Step 4: 验证**
|
|
644
|
+
|
|
645
|
+
Run: `bun run typecheck`(仓库根)和 `bun test`
|
|
646
|
+
Expected: 无错误,全部通过
|
|
647
|
+
|
|
648
|
+
- [ ] **Step 5: 提交**
|
|
649
|
+
|
|
650
|
+
```bash
|
|
651
|
+
git add src/cli.ts src/config.ts src/agent.ts src/tui-chat.ts
|
|
652
|
+
git commit -m "feat: CLI --provider 与交互式多 provider 配置"
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
---
|
|
656
|
+
|
|
657
|
+
## Task 4: TUI /provider + serve 适配
|
|
658
|
+
|
|
659
|
+
**Files:**
|
|
660
|
+
- Modify: `src/tui-chat.ts`
|
|
661
|
+
- Modify: `src/serve.ts`
|
|
662
|
+
- Test: `tests/serve.test.ts`
|
|
663
|
+
|
|
664
|
+
- [ ] **Step 1: 写失败测试**
|
|
665
|
+
|
|
666
|
+
在 `tests/serve.test.ts` 末尾追加:
|
|
667
|
+
|
|
668
|
+
```ts
|
|
669
|
+
test("F9: /v1/models 使用 activeProvider", async () => {
|
|
670
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/models`)
|
|
671
|
+
expect(res.status).toBe(200)
|
|
672
|
+
const body = await res.json()
|
|
673
|
+
expect(body.default_model).toBe(BAD_MODEL)
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
test("F9: POST /v1/chat 接受 provider 字段不报错", async () => {
|
|
677
|
+
const { status } = await postJson("/v1/chat", {
|
|
678
|
+
message: "hi",
|
|
679
|
+
stream: false,
|
|
680
|
+
provider: "default",
|
|
681
|
+
})
|
|
682
|
+
expect(status).toBe(200)
|
|
683
|
+
}, 90000)
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
(beforeAll 的 config.json 已是 providers 结构——**需要更新**:Task 1 迁移后 serve 测试的 beforeAll 写的是旧格式,serve 启动时会迁移。为明确,将 beforeAll 的 config.json 改为新格式:
|
|
687
|
+
|
|
688
|
+
```ts
|
|
689
|
+
writeFileSync(
|
|
690
|
+
path.join(configDir, "config.json"),
|
|
691
|
+
JSON.stringify({
|
|
692
|
+
providers: [{ name: "default", baseURL: "http://127.0.0.1:1/v1", apiKey: "test", defaultModel: BAD_MODEL }],
|
|
693
|
+
activeProvider: "default",
|
|
694
|
+
pricing: { [BAD_MODEL]: { inputPerMillion: 10, outputPerMillion: 20 } },
|
|
695
|
+
}),
|
|
696
|
+
)
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
- [ ] **Step 2: 运行测试确认失败**
|
|
702
|
+
|
|
703
|
+
Run: `bun test tests/serve.test.ts`
|
|
704
|
+
Expected: 新用例 FAIL(models 路由 500 或 provider 字段未识别)
|
|
705
|
+
|
|
706
|
+
- [ ] **Step 3: 修改 tui-chat.ts**
|
|
707
|
+
|
|
708
|
+
**/provider 命令**(在 /model case 之前添加):
|
|
709
|
+
|
|
710
|
+
```ts
|
|
711
|
+
case "provider": {
|
|
712
|
+
const cfg = loadConfig()
|
|
713
|
+
const providers = cfg.providers ?? []
|
|
714
|
+
if (arg) {
|
|
715
|
+
const target = providers.find((p) => p.name === arg)
|
|
716
|
+
if (!target) {
|
|
717
|
+
sysMsg(tui, `Provider "${arg}" 不存在`)
|
|
718
|
+
break
|
|
719
|
+
}
|
|
720
|
+
cfg.activeProvider = arg
|
|
721
|
+
saveConfig(cfg)
|
|
722
|
+
setModel(target.defaultModel ?? "")
|
|
723
|
+
sysMsg(tui, `✓ 当前 Provider 已切换为: ${arg}`)
|
|
724
|
+
} else {
|
|
725
|
+
if (providers.length === 0) {
|
|
726
|
+
sysMsg(tui, "无已配置 Provider,运行: min-agent setup")
|
|
727
|
+
break
|
|
728
|
+
}
|
|
729
|
+
const lines = providers.map((p) => {
|
|
730
|
+
const cur = p.name === cfg.activeProvider ? " ← 当前" : ""
|
|
731
|
+
return ` ${p.name} ${p.baseURL}${cur}`
|
|
732
|
+
})
|
|
733
|
+
sysMsg(tui, `Providers:\n${lines.join("\n")}\n\n切换: /provider <name>`)
|
|
734
|
+
}
|
|
735
|
+
break
|
|
736
|
+
}
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
**/models 分支适配**(改用 getActiveProvider):
|
|
740
|
+
|
|
741
|
+
```ts
|
|
742
|
+
case "models": {
|
|
743
|
+
const { fetchModels } = await import("./config.js")
|
|
744
|
+
const cfg = loadConfig()
|
|
745
|
+
const provider = getActiveProvider(cfg)
|
|
746
|
+
if (!provider?.baseURL || !provider?.apiKey) {
|
|
747
|
+
sysMsg(tui, "未配置 Provider,运行: min-agent setup")
|
|
748
|
+
break
|
|
749
|
+
}
|
|
750
|
+
sysMsg(tui, "正在获取模型列表...")
|
|
751
|
+
const models = await fetchModels(provider.baseURL, provider.apiKey)
|
|
752
|
+
if (models.length === 0) {
|
|
753
|
+
sysMsg(tui, "无法获取模型列表")
|
|
754
|
+
} else {
|
|
755
|
+
const lines = models.map((m) => {
|
|
756
|
+
const marker = m === provider.defaultModel ? " ← 默认" : ""
|
|
757
|
+
return ` ${m}${marker}`
|
|
758
|
+
})
|
|
759
|
+
sysMsg(tui, `可用模型 (${models.length}):\n${lines.join("\n")}`)
|
|
760
|
+
}
|
|
761
|
+
break
|
|
762
|
+
}
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
**/model 分支适配**(保存到 activeProvider):
|
|
766
|
+
|
|
767
|
+
```ts
|
|
768
|
+
case "model": {
|
|
769
|
+
if (arg) {
|
|
770
|
+
const config = await import("./config.js")
|
|
771
|
+
const cfg = config.loadConfig()
|
|
772
|
+
const provider = getActiveProvider(cfg)
|
|
773
|
+
if (provider) {
|
|
774
|
+
provider.defaultModel = arg
|
|
775
|
+
config.saveConfig(cfg)
|
|
776
|
+
}
|
|
777
|
+
setModel(arg)
|
|
778
|
+
sysMsg(tui, `✓ 默认模型已设为: ${arg}`)
|
|
779
|
+
} else {
|
|
780
|
+
tui.showModelPicker()
|
|
781
|
+
}
|
|
782
|
+
break
|
|
783
|
+
}
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
**onModelPick 回调适配**(runTui 内约 131 行):
|
|
787
|
+
|
|
788
|
+
```ts
|
|
789
|
+
onModelPick: (model: string) => {
|
|
790
|
+
tui.hideModelPicker()
|
|
791
|
+
const cfg = loadConfig()
|
|
792
|
+
const provider = getActiveProvider(cfg)
|
|
793
|
+
if (provider) {
|
|
794
|
+
provider.defaultModel = model
|
|
795
|
+
saveConfig(cfg)
|
|
796
|
+
}
|
|
797
|
+
currentModel = model
|
|
798
|
+
tui.setModel(model)
|
|
799
|
+
sysMsg(tui, `✓ 默认模型已设为: ${model}`)
|
|
800
|
+
},
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
**runTui 启动处适配**(`config.provider?.defaultModel` 引用,约 50、147 行):
|
|
804
|
+
|
|
805
|
+
```ts
|
|
806
|
+
const config = loadConfig()
|
|
807
|
+
const activeProvider = getActiveProvider(config)
|
|
808
|
+
...
|
|
809
|
+
let currentModel = modelId ?? activeProvider?.defaultModel
|
|
810
|
+
...
|
|
811
|
+
new TuiRenderer(callbacks, modelId ?? activeProvider?.defaultModel)
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
(tui-chat.ts import 加 getActiveProvider)
|
|
815
|
+
|
|
816
|
+
- [ ] **Step 4: 修改 serve.ts**
|
|
817
|
+
|
|
818
|
+
**a)** import 加 getActiveProvider:
|
|
819
|
+
|
|
820
|
+
```ts
|
|
821
|
+
import { loadConfig, saveConfig, fetchModels, isConfigured, getActiveProvider } from "./config.js"
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
**b)** `/v1/models` 路由:
|
|
825
|
+
|
|
826
|
+
```ts
|
|
827
|
+
if (req.method === "GET" && pathname === "/v1/models") {
|
|
828
|
+
const config = loadConfig()
|
|
829
|
+
const provider = getActiveProvider(config)
|
|
830
|
+
const base = provider?.baseURL
|
|
831
|
+
const key = provider?.apiKey
|
|
832
|
+
if (!base || !key) {
|
|
833
|
+
sendJson(res, 500, { error: "provider_not_configured" })
|
|
834
|
+
return
|
|
835
|
+
}
|
|
836
|
+
const models = await fetchModels(base, key)
|
|
837
|
+
sendJson(res, 200, {
|
|
838
|
+
default_model: provider?.defaultModel ?? null,
|
|
839
|
+
models,
|
|
840
|
+
})
|
|
841
|
+
return
|
|
842
|
+
}
|
|
843
|
+
```
|
|
844
|
+
|
|
845
|
+
**c)** `/v1/context` 与 `/v1/cost` 路由(provider 引用):
|
|
846
|
+
|
|
847
|
+
```ts
|
|
848
|
+
if (req.method === "GET" && pathname === "/v1/context") {
|
|
849
|
+
const config = loadConfig()
|
|
850
|
+
const provider = getActiveProvider(config)
|
|
851
|
+
const ctxWindow = await getContextWindow(provider?.defaultModel)
|
|
852
|
+
sendJson(res, 200, {
|
|
853
|
+
context_window: ctxWindow,
|
|
854
|
+
model: provider?.defaultModel ?? null,
|
|
855
|
+
})
|
|
856
|
+
return
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (req.method === "GET" && pathname === "/v1/cost") {
|
|
860
|
+
const config = loadConfig()
|
|
861
|
+
const provider = getActiveProvider(config)
|
|
862
|
+
const modelId = url.searchParams.get("model") ?? provider?.defaultModel ?? null
|
|
863
|
+
...
|
|
864
|
+
}
|
|
865
|
+
```
|
|
866
|
+
|
|
867
|
+
**d)** ChatBody 加 provider 字段,handleChatRequest 的 runOptions 加 providerName:
|
|
868
|
+
|
|
869
|
+
```ts
|
|
870
|
+
interface ChatBody {
|
|
871
|
+
...
|
|
872
|
+
provider?: string
|
|
873
|
+
...
|
|
874
|
+
}
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
```ts
|
|
878
|
+
const runOptions: { temperature?: number; maxTokens?: number; topP?: number; providerName?: string } = {
|
|
879
|
+
temperature: ...,
|
|
880
|
+
maxTokens: ...,
|
|
881
|
+
topP: ...,
|
|
882
|
+
providerName: typeof body.provider === "string" ? body.provider : undefined,
|
|
883
|
+
}
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
- [ ] **Step 5: 运行测试确认通过**
|
|
887
|
+
|
|
888
|
+
Run: `bun test tests/serve.test.ts`
|
|
889
|
+
Expected: PASS(原有 + 2 个新用例)
|
|
890
|
+
|
|
891
|
+
- [ ] **Step 6: 提交**
|
|
892
|
+
|
|
893
|
+
```bash
|
|
894
|
+
git add src/tui-chat.ts src/serve.ts tests/serve.test.ts
|
|
895
|
+
git commit -m "feat: TUI /provider 与 serve activeProvider 适配"
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
---
|
|
899
|
+
|
|
900
|
+
## Task 5: sampling 配置回退 + 预算上限
|
|
901
|
+
|
|
902
|
+
**Files:**
|
|
903
|
+
- Modify: `src/agent.ts`
|
|
904
|
+
- Modify: `src/tui-chat.ts`
|
|
905
|
+
- Modify: `src/serve.ts`
|
|
906
|
+
- Test: `tests/serve.test.ts`(budget_exceeded 字段)
|
|
907
|
+
|
|
908
|
+
- [ ] **Step 1: 写失败测试**
|
|
909
|
+
|
|
910
|
+
在 `tests/serve.test.ts` 末尾追加:
|
|
911
|
+
|
|
912
|
+
```ts
|
|
913
|
+
test("F9: done 事件包含 budget_exceeded 字段", async () => {
|
|
914
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/chat`, {
|
|
915
|
+
method: "POST",
|
|
916
|
+
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
917
|
+
body: JSON.stringify({ message: "hi", stream: true }),
|
|
918
|
+
})
|
|
919
|
+
const sse = parseSse(await res.text())
|
|
920
|
+
const done = sse.find((e) => e.type === "done")
|
|
921
|
+
expect(done).toBeTruthy()
|
|
922
|
+
expect(typeof (done as any).budget_exceeded).toBe("boolean")
|
|
923
|
+
}, 90000)
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
- [ ] **Step 2: 运行测试确认失败**
|
|
927
|
+
|
|
928
|
+
Run: `bun test tests/serve.test.ts`
|
|
929
|
+
Expected: 新用例 FAIL(budget_exceeded undefined)
|
|
930
|
+
|
|
931
|
+
- [ ] **Step 3: 修改 agent.ts**
|
|
932
|
+
|
|
933
|
+
**a)** `RunOnceCallbacks.onRunFinish` 的 info 加字段:
|
|
934
|
+
|
|
935
|
+
```ts
|
|
936
|
+
onRunFinish?: (info: {
|
|
937
|
+
stepCount: number
|
|
938
|
+
usage: LanguageModelUsage | undefined
|
|
939
|
+
hasError: boolean
|
|
940
|
+
aborted: boolean
|
|
941
|
+
maxStepsReached: boolean
|
|
942
|
+
budgetExceeded?: boolean
|
|
943
|
+
}) => void
|
|
944
|
+
```
|
|
945
|
+
|
|
946
|
+
**b)** runOnceCore 采样回退(`const model = resolveModel(...)` 之后):
|
|
947
|
+
|
|
948
|
+
```ts
|
|
949
|
+
const sampling = loadConfig().sampling ?? {}
|
|
950
|
+
const temperature = options?.temperature ?? sampling.temperature
|
|
951
|
+
const maxTokens = options?.maxTokens ?? sampling.maxTokens
|
|
952
|
+
const topP = options?.topP ?? sampling.topP
|
|
953
|
+
```
|
|
954
|
+
|
|
955
|
+
streamText 条件展开改用这些变量:
|
|
956
|
+
|
|
957
|
+
```ts
|
|
958
|
+
...(temperature != null ? { temperature } : {}),
|
|
959
|
+
...(maxTokens != null ? { maxTokens } : {}),
|
|
960
|
+
...(topP != null ? { topP } : {}),
|
|
961
|
+
```
|
|
962
|
+
|
|
963
|
+
(agent.ts 需 import loadConfig、getActiveProvider)
|
|
964
|
+
|
|
965
|
+
**c)** 预算检查(runOnceCore 内,`maxStepsReached` 逻辑附近):
|
|
966
|
+
|
|
967
|
+
```ts
|
|
968
|
+
let budgetExceeded = false
|
|
969
|
+
const checkBudget = async () => {
|
|
970
|
+
if (!tracker) return
|
|
971
|
+
const maxCost = loadConfig().budget?.maxCostUSD
|
|
972
|
+
if (maxCost == null || maxCost <= 0) return
|
|
973
|
+
const price = await getModelPrice(modelId ?? getActiveProvider(loadConfig())?.defaultModel ?? "")
|
|
974
|
+
const cost = estimateCost(
|
|
975
|
+
{ inputTokens: tracker.totalInputTokens, outputTokens: tracker.totalOutputTokens },
|
|
976
|
+
price,
|
|
977
|
+
)
|
|
978
|
+
if (cost != null && cost > maxCost) budgetExceeded = true
|
|
979
|
+
}
|
|
980
|
+
```
|
|
981
|
+
|
|
982
|
+
(agent.ts 需 import getModelPrice、estimateCost from "./pricing.js",getActiveProvider from "./config.js")
|
|
983
|
+
|
|
984
|
+
在成功路径与 catch 路径的 onRunFinish 调用前 `await checkBudget()`,并在 info 中传 `budgetExceeded`:
|
|
985
|
+
|
|
986
|
+
```ts
|
|
987
|
+
if (cbs.onRunFinish) {
|
|
988
|
+
await checkBudget()
|
|
989
|
+
cbs.onRunFinish({ stepCount, usage, hasError, aborted: false, maxStepsReached, budgetExceeded })
|
|
990
|
+
}
|
|
991
|
+
```
|
|
992
|
+
|
|
993
|
+
catch 路径两处同样 `await checkBudget()` 并传字段(aborted 路径可不检查——aborted 时已返回,保持简单:aborted 分支传 `budgetExceeded: false`)。
|
|
994
|
+
|
|
995
|
+
- [ ] **Step 4: 修改 tui-chat.ts**
|
|
996
|
+
|
|
997
|
+
**onRunFinish** 完成消息追加预算提示(info 解构后):
|
|
998
|
+
|
|
999
|
+
```ts
|
|
1000
|
+
onRunFinish: async (info) => {
|
|
1001
|
+
abortController = null
|
|
1002
|
+
tui.setRunning(false)
|
|
1003
|
+
if (info.aborted) return
|
|
1004
|
+
if (info.budgetExceeded) {
|
|
1005
|
+
tui.addMessage({
|
|
1006
|
+
id: `budget-${Date.now()}`,
|
|
1007
|
+
role: "system",
|
|
1008
|
+
content: "⚠ 已达到预算上限,后续回合可能被限制。/budget 查看或调高",
|
|
1009
|
+
timestamp: Date.now(),
|
|
1010
|
+
})
|
|
1011
|
+
}
|
|
1012
|
+
...
|
|
1013
|
+
```
|
|
1014
|
+
|
|
1015
|
+
**/budget 命令**(/tokens 附近):
|
|
1016
|
+
|
|
1017
|
+
```ts
|
|
1018
|
+
case "budget": {
|
|
1019
|
+
const cfg = loadConfig()
|
|
1020
|
+
const maxCost = cfg.budget?.maxCostUSD
|
|
1021
|
+
if (arg) {
|
|
1022
|
+
const n = parseFloat(arg)
|
|
1023
|
+
if (isNaN(n) || n <= 0) {
|
|
1024
|
+
sysMsg(tui, "用法: /budget <USD 金额>")
|
|
1025
|
+
break
|
|
1026
|
+
}
|
|
1027
|
+
cfg.budget = { maxCostUSD: n }
|
|
1028
|
+
saveConfig(cfg)
|
|
1029
|
+
sysMsg(tui, `✓ 预算上限已设为: $${n}`)
|
|
1030
|
+
break
|
|
1031
|
+
}
|
|
1032
|
+
const price = await getModelPrice(currentModel ?? "")
|
|
1033
|
+
const cost = formatCost(
|
|
1034
|
+
estimateCost(
|
|
1035
|
+
{ inputTokens: tracker.totalInputTokens, outputTokens: tracker.totalOutputTokens },
|
|
1036
|
+
price,
|
|
1037
|
+
),
|
|
1038
|
+
)
|
|
1039
|
+
sysMsg(tui, maxCost != null
|
|
1040
|
+
? `预算: 已用约 ${cost} / 上限 $${maxCost}\n设置: /budget <金额>`
|
|
1041
|
+
: `预算: 已用约 ${cost} / 未设置上限\n设置: /budget <金额>`)
|
|
1042
|
+
break
|
|
1043
|
+
}
|
|
1044
|
+
```
|
|
1045
|
+
|
|
1046
|
+
(getModelPrice/estimateCost/formatCost 已在第一批 import)
|
|
1047
|
+
|
|
1048
|
+
- [ ] **Step 5: 修改 serve.ts 的 done 事件**
|
|
1049
|
+
|
|
1050
|
+
`createSseCallbacks` 的 onRunFinish(约 190-211 行)done 事件加字段:
|
|
1051
|
+
|
|
1052
|
+
```ts
|
|
1053
|
+
sseWrite(res, {
|
|
1054
|
+
type: "done",
|
|
1055
|
+
step_count: info.stepCount,
|
|
1056
|
+
usage: info.usage,
|
|
1057
|
+
has_error: info.hasError,
|
|
1058
|
+
aborted: info.aborted,
|
|
1059
|
+
max_steps_reached: info.maxStepsReached,
|
|
1060
|
+
budget_exceeded: info.budgetExceeded ?? false,
|
|
1061
|
+
session_id: saved,
|
|
1062
|
+
messages,
|
|
1063
|
+
...(opts.codeMode ? { mode: "code", project: opts.project } : {}),
|
|
1064
|
+
})
|
|
1065
|
+
```
|
|
1066
|
+
|
|
1067
|
+
- [ ] **Step 6: 验证**
|
|
1068
|
+
|
|
1069
|
+
Run: `bun run typecheck`(仓库根)和 `bun test`
|
|
1070
|
+
Expected: 无错误,全部通过
|
|
1071
|
+
|
|
1072
|
+
- [ ] **Step 7: 提交**
|
|
1073
|
+
|
|
1074
|
+
```bash
|
|
1075
|
+
git add src/agent.ts src/tui-chat.ts src/serve.ts tests/serve.test.ts
|
|
1076
|
+
git commit -m "feat: 采样参数配置回退与预算上限"
|
|
1077
|
+
```
|
|
1078
|
+
|
|
1079
|
+
---
|
|
1080
|
+
|
|
1081
|
+
## Task 6: logger.ts 日志系统
|
|
1082
|
+
|
|
1083
|
+
**Files:**
|
|
1084
|
+
- Create: `src/logger.ts`
|
|
1085
|
+
- Modify: `src/agent.ts`
|
|
1086
|
+
- Modify: `src/cli.ts`
|
|
1087
|
+
- Test: `tests/logger.test.ts`
|
|
1088
|
+
|
|
1089
|
+
- [ ] **Step 1: 写失败测试**
|
|
1090
|
+
|
|
1091
|
+
创建 `tests/logger.test.ts`:
|
|
1092
|
+
|
|
1093
|
+
```ts
|
|
1094
|
+
import { expect, test, beforeEach, afterEach } from "bun:test"
|
|
1095
|
+
import { mkdtempSync, rmSync, writeFileSync, readdirSync, readFileSync, realpathSync } from "fs"
|
|
1096
|
+
import os from "os"
|
|
1097
|
+
import path from "path"
|
|
1098
|
+
import { initLogger, log, logToolCall, logToolResult } from "../src/logger.js"
|
|
1099
|
+
|
|
1100
|
+
let configDir = ""
|
|
1101
|
+
let originalConfigDir: string | undefined
|
|
1102
|
+
|
|
1103
|
+
beforeEach(() => {
|
|
1104
|
+
configDir = realpathSync(mkdtempSync(path.join(os.tmpdir(), "min-agent-logger-")))
|
|
1105
|
+
originalConfigDir = process.env.MIN_AGENT_CONFIG_DIR
|
|
1106
|
+
process.env.MIN_AGENT_CONFIG_DIR = configDir
|
|
1107
|
+
})
|
|
1108
|
+
|
|
1109
|
+
afterEach(() => {
|
|
1110
|
+
if (originalConfigDir === undefined) delete process.env.MIN_AGENT_CONFIG_DIR
|
|
1111
|
+
else process.env.MIN_AGENT_CONFIG_DIR = originalConfigDir
|
|
1112
|
+
rmSync(configDir, { recursive: true, force: true })
|
|
1113
|
+
})
|
|
1114
|
+
|
|
1115
|
+
function todayLog(): string {
|
|
1116
|
+
const now = new Date()
|
|
1117
|
+
const m = String(now.getMonth() + 1).padStart(2, "0")
|
|
1118
|
+
const d = String(now.getDate()).padStart(2, "0")
|
|
1119
|
+
return path.join(configDir, "logs", `min-agent.${now.getFullYear()}-${m}-${d}.log`)
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
test("log writes to the daily file with timestamp and level", () => {
|
|
1123
|
+
initLogger()
|
|
1124
|
+
log("info", "hello")
|
|
1125
|
+
const content = readFileSync(todayLog(), "utf-8")
|
|
1126
|
+
expect(content).toContain("[info] hello")
|
|
1127
|
+
expect(content).toMatch(/^\[\d{2}:\d{2}:\d{2}\] \[info\] hello/)
|
|
1128
|
+
})
|
|
1129
|
+
|
|
1130
|
+
test("logToolCall and logToolResult record summarized input", () => {
|
|
1131
|
+
initLogger()
|
|
1132
|
+
logToolCall("bash", { command: "echo hi", timeout: 5 })
|
|
1133
|
+
const content = readFileSync(todayLog(), "utf-8")
|
|
1134
|
+
expect(content).toContain("tool_call bash")
|
|
1135
|
+
expect(content).toContain("echo hi")
|
|
1136
|
+
})
|
|
1137
|
+
|
|
1138
|
+
test("initLogger cleans up logs older than 7 days", () => {
|
|
1139
|
+
const logsDir = path.join(configDir, "logs")
|
|
1140
|
+
writeFileSync(path.join(logsDir, "min-agent.2020-01-01.log"), "old", { flag: "a" })
|
|
1141
|
+
writeFileSync(path.join(logsDir, "min-agent.2099-01-01.log"), "future", { flag: "a" })
|
|
1142
|
+
initLogger()
|
|
1143
|
+
const files = readdirSync(logsDir)
|
|
1144
|
+
expect(files).not.toContain("min-agent.2020-01-01.log")
|
|
1145
|
+
expect(files).toContain("min-agent.2099-01-01.log")
|
|
1146
|
+
})
|
|
1147
|
+
```
|
|
1148
|
+
|
|
1149
|
+
(注意:initLogger 会创建 logsDir——第三个用例先手动写文件需要目录存在:用 `mkdirSync(logsDir, { recursive: true })` 补 import;或直接依赖 writeFileSync 的父目录——writeFileSync 不创建父目录,需 mkdirSync)
|
|
1150
|
+
|
|
1151
|
+
- [ ] **Step 2: 运行测试确认失败**
|
|
1152
|
+
|
|
1153
|
+
Run: `bun test tests/logger.test.ts`
|
|
1154
|
+
Expected: FAIL — module not found
|
|
1155
|
+
|
|
1156
|
+
- [ ] **Step 3: 实现 logger.ts**
|
|
1157
|
+
|
|
1158
|
+
```ts
|
|
1159
|
+
import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from "fs"
|
|
1160
|
+
import path from "path"
|
|
1161
|
+
import { getConfigDir } from "./config.js"
|
|
1162
|
+
|
|
1163
|
+
const KEEP_DAYS = 7
|
|
1164
|
+
|
|
1165
|
+
function logsDir(): string {
|
|
1166
|
+
return path.join(getConfigDir(), "logs")
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function logFilePath(date: Date): string {
|
|
1170
|
+
const y = date.getFullYear()
|
|
1171
|
+
const m = String(date.getMonth() + 1).padStart(2, "0")
|
|
1172
|
+
const d = String(date.getDate()).padStart(2, "0")
|
|
1173
|
+
return path.join(logsDir(), `min-agent.${y}-${m}-${d}.log`)
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
export function initLogger(): void {
|
|
1177
|
+
try {
|
|
1178
|
+
mkdirSync(logsDir(), { recursive: true })
|
|
1179
|
+
const cutoff = Date.now() - KEEP_DAYS * 24 * 60 * 60 * 1000
|
|
1180
|
+
for (const f of readdirSync(logsDir())) {
|
|
1181
|
+
const m = f.match(/^min-agent\.(\d{4})-(\d{2})-(\d{2})\.log$/)
|
|
1182
|
+
if (!m) continue
|
|
1183
|
+
const ts = new Date(`${m[1]}-${m[2]}-${m[3]}T00:00:00`).getTime()
|
|
1184
|
+
if (ts < cutoff) {
|
|
1185
|
+
try {
|
|
1186
|
+
unlinkSync(path.join(logsDir(), f))
|
|
1187
|
+
} catch {}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
} catch {}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function writeLog(level: string, msg: string): void {
|
|
1194
|
+
try {
|
|
1195
|
+
const now = new Date()
|
|
1196
|
+
const time =
|
|
1197
|
+
`${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`
|
|
1198
|
+
writeFileSync(logFilePath(now), `[${time}] [${level}] ${msg}\n`, { flag: "a" })
|
|
1199
|
+
} catch {}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
export function log(level: "info" | "warn" | "error", msg: string): void {
|
|
1203
|
+
writeLog(level, msg)
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function summarize(v: unknown, max = 200): string {
|
|
1207
|
+
const s = typeof v === "string" ? v : JSON.stringify(v) ?? String(v)
|
|
1208
|
+
return s.length > max ? s.slice(0, max) + "…" : s
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
export function logToolCall(name: string, input: unknown): void {
|
|
1212
|
+
writeLog("info", `tool_call ${name} ${summarize(input)}`)
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
export function logToolResult(name: string, output: unknown): void {
|
|
1216
|
+
writeLog("info", `tool_result ${name} ${summarize(output)}`)
|
|
1217
|
+
}
|
|
1218
|
+
```
|
|
1219
|
+
|
|
1220
|
+
- [ ] **Step 4: agent.ts 记录点**
|
|
1221
|
+
|
|
1222
|
+
import:
|
|
1223
|
+
|
|
1224
|
+
```ts
|
|
1225
|
+
import { initLogger, log, logToolCall, logToolResult } from "./logger.js"
|
|
1226
|
+
```
|
|
1227
|
+
|
|
1228
|
+
- runOnceCore 开头(`const model = resolveModel(...)` 之后):
|
|
1229
|
+
|
|
1230
|
+
```ts
|
|
1231
|
+
log("info", `run start mode=${mode} messages=${messages.length} model=${model.modelId}`)
|
|
1232
|
+
```
|
|
1233
|
+
|
|
1234
|
+
- tool-call 事件(case "tool-call" 内):
|
|
1235
|
+
|
|
1236
|
+
```ts
|
|
1237
|
+
logToolCall(event.toolName, event.input)
|
|
1238
|
+
```
|
|
1239
|
+
|
|
1240
|
+
- tool-result 事件(case "tool-result"/"tool-error" 内):
|
|
1241
|
+
|
|
1242
|
+
```ts
|
|
1243
|
+
logToolResult(event.toolName, displayed)
|
|
1244
|
+
```
|
|
1245
|
+
|
|
1246
|
+
- error 事件(case "error" 内):
|
|
1247
|
+
|
|
1248
|
+
```ts
|
|
1249
|
+
log("error", `stream error: ${String(event.error)}`)
|
|
1250
|
+
```
|
|
1251
|
+
|
|
1252
|
+
- catch 路径(`const msg = err instanceof Error ? err.message : String(err)` 附近):
|
|
1253
|
+
|
|
1254
|
+
```ts
|
|
1255
|
+
log("error", msg)
|
|
1256
|
+
```
|
|
1257
|
+
|
|
1258
|
+
- 成功结束(`if (cbs.onRunFinish) {` 之前):
|
|
1259
|
+
|
|
1260
|
+
```ts
|
|
1261
|
+
log("info", `run end steps=${stepCount}`)
|
|
1262
|
+
```
|
|
1263
|
+
|
|
1264
|
+
(注意放在 pushTurn 之后、usage 更新附近;只加一次)
|
|
1265
|
+
|
|
1266
|
+
- [ ] **Step 5: cli.ts 启动记录**
|
|
1267
|
+
|
|
1268
|
+
`main()` 开头(args 检查之后):
|
|
1269
|
+
|
|
1270
|
+
```ts
|
|
1271
|
+
initLogger()
|
|
1272
|
+
log("info", `min-agent started: ${command}`)
|
|
1273
|
+
```
|
|
1274
|
+
|
|
1275
|
+
(import from "./logger.js")
|
|
1276
|
+
|
|
1277
|
+
- [ ] **Step 6: 运行测试确认通过**
|
|
1278
|
+
|
|
1279
|
+
Run: `bun test tests/logger.test.ts` 与 `bun test` 全量、`bun run typecheck`
|
|
1280
|
+
Expected: 全部通过
|
|
1281
|
+
|
|
1282
|
+
- [ ] **Step 7: 提交**
|
|
1283
|
+
|
|
1284
|
+
```bash
|
|
1285
|
+
git add src/logger.ts src/agent.ts src/cli.ts tests/logger.test.ts
|
|
1286
|
+
git commit -m "feat: 日期滚动运行日志"
|
|
1287
|
+
```
|
|
1288
|
+
|
|
1289
|
+
---
|
|
1290
|
+
|
|
1291
|
+
## Task 7: CI
|
|
1292
|
+
|
|
1293
|
+
**Files:**
|
|
1294
|
+
- Create: `.github/workflows/ci.yml`(仓库根目录)
|
|
1295
|
+
|
|
1296
|
+
- [ ] **Step 1: 创建 CI 文件**
|
|
1297
|
+
|
|
1298
|
+
在仓库根 `/Users/kevin/Desktop/project/min-agent/.github/workflows/ci.yml` 创建:
|
|
1299
|
+
|
|
1300
|
+
```yaml
|
|
1301
|
+
name: CI
|
|
1302
|
+
|
|
1303
|
+
on:
|
|
1304
|
+
push:
|
|
1305
|
+
branches: [master]
|
|
1306
|
+
pull_request:
|
|
1307
|
+
|
|
1308
|
+
jobs:
|
|
1309
|
+
test:
|
|
1310
|
+
runs-on: ubuntu-latest
|
|
1311
|
+
steps:
|
|
1312
|
+
- uses: actions/checkout@v4
|
|
1313
|
+
- uses: oven-sh/setup-bun@v2
|
|
1314
|
+
with:
|
|
1315
|
+
bun-version: latest
|
|
1316
|
+
- run: bun install --frozen-lockfile
|
|
1317
|
+
- run: bun run typecheck
|
|
1318
|
+
- run: cd packages/min-agent && bun test
|
|
1319
|
+
```
|
|
1320
|
+
|
|
1321
|
+
- [ ] **Step 2: 验证 YAML 语法**
|
|
1322
|
+
|
|
1323
|
+
Run: `bun -e "const y = await Bun.file('.github/workflows/ci.yml').text(); console.log('ok', y.length)"`(仓库根)
|
|
1324
|
+
Expected: ok + 长度
|
|
1325
|
+
|
|
1326
|
+
- [ ] **Step 3: 提交**
|
|
1327
|
+
|
|
1328
|
+
```bash
|
|
1329
|
+
git add .github/workflows/ci.yml
|
|
1330
|
+
git commit -m "ci: GitHub Actions 类型检查与测试"
|
|
1331
|
+
```
|
|
1332
|
+
|
|
1333
|
+
---
|
|
1334
|
+
|
|
1335
|
+
## Task 8: /plan 计划模式
|
|
1336
|
+
|
|
1337
|
+
**Files:**
|
|
1338
|
+
- Modify: `src/agent.ts`(buildTools planMode 过滤)
|
|
1339
|
+
- Modify: `src/tui-chat.ts`(/plan 命令)
|
|
1340
|
+
|
|
1341
|
+
- [ ] **Step 1: agent.ts buildTools 加 planMode**
|
|
1342
|
+
|
|
1343
|
+
```ts
|
|
1344
|
+
async function buildTools(
|
|
1345
|
+
mode: "chat" | "code",
|
|
1346
|
+
modelId: string | undefined,
|
|
1347
|
+
abortSignal: AbortSignal | undefined,
|
|
1348
|
+
planMode = false,
|
|
1349
|
+
): Promise<Record<string, Tool>> {
|
|
1350
|
+
...
|
|
1351
|
+
if (planMode) {
|
|
1352
|
+
for (const name of ["bash", "write", "edit", "apply_patch"]) delete allTools[name]
|
|
1353
|
+
}
|
|
1354
|
+
return allTools
|
|
1355
|
+
}
|
|
1356
|
+
```
|
|
1357
|
+
|
|
1358
|
+
runOnceCore 调用处:
|
|
1359
|
+
|
|
1360
|
+
```ts
|
|
1361
|
+
const allTools = await buildTools(mode, modelId, abortSignal, options?.planMode)
|
|
1362
|
+
```
|
|
1363
|
+
|
|
1364
|
+
- [ ] **Step 2: tui-chat.ts /plan**
|
|
1365
|
+
|
|
1366
|
+
**状态变量**(undoStack 附近):
|
|
1367
|
+
|
|
1368
|
+
```ts
|
|
1369
|
+
let planMode = false
|
|
1370
|
+
```
|
|
1371
|
+
|
|
1372
|
+
**executeAgent 的 runOptions**(现有 `const runOptions = { providerName: currentProvider }`)改为:
|
|
1373
|
+
|
|
1374
|
+
```ts
|
|
1375
|
+
const runOptions: { providerName?: string; planMode?: boolean } = {
|
|
1376
|
+
providerName: currentProvider,
|
|
1377
|
+
...(planMode ? { planMode: true } : {}),
|
|
1378
|
+
}
|
|
1379
|
+
```
|
|
1380
|
+
|
|
1381
|
+
**/plan 命令**(/diff 附近):
|
|
1382
|
+
|
|
1383
|
+
```ts
|
|
1384
|
+
case "plan": {
|
|
1385
|
+
state.togglePlanMode()
|
|
1386
|
+
sysMsg(tui, state.planMode
|
|
1387
|
+
? "✓ 计划模式已开启:仅只读工具可用,输出计划后再次输入 /plan 确认并执行"
|
|
1388
|
+
: "✓ 已退出计划模式")
|
|
1389
|
+
break
|
|
1390
|
+
}
|
|
1391
|
+
```
|
|
1392
|
+
|
|
1393
|
+
SlashState 接口加 planMode/togglePlanMode:
|
|
1394
|
+
|
|
1395
|
+
```ts
|
|
1396
|
+
interface SlashState {
|
|
1397
|
+
...
|
|
1398
|
+
planMode: boolean
|
|
1399
|
+
togglePlanMode: () => void
|
|
1400
|
+
}
|
|
1401
|
+
```
|
|
1402
|
+
|
|
1403
|
+
调用处传:
|
|
1404
|
+
|
|
1405
|
+
```ts
|
|
1406
|
+
planMode,
|
|
1407
|
+
togglePlanMode: () => { planMode = !planMode },
|
|
1408
|
+
```
|
|
1409
|
+
|
|
1410
|
+
- [ ] **Step 3: 验证**
|
|
1411
|
+
|
|
1412
|
+
Run: `bun run typecheck`(仓库根)和 `bun test`
|
|
1413
|
+
Expected: 无错误,全部通过
|
|
1414
|
+
|
|
1415
|
+
- [ ] **Step 4: 提交**
|
|
1416
|
+
|
|
1417
|
+
```bash
|
|
1418
|
+
git add src/agent.ts src/tui-chat.ts
|
|
1419
|
+
git commit -m "feat: /plan 只读计划模式"
|
|
1420
|
+
```
|
|
1421
|
+
|
|
1422
|
+
---
|
|
1423
|
+
|
|
1424
|
+
## Task 9: 文档同步
|
|
1425
|
+
|
|
1426
|
+
**Files:**
|
|
1427
|
+
- Modify: `src/cli.ts`(printUsage)
|
|
1428
|
+
- Modify: `src/tui/slash-commands.ts`
|
|
1429
|
+
- Modify: `src/tui-chat.ts`(/help)
|
|
1430
|
+
- Modify: `README.md`
|
|
1431
|
+
- Modify: `docs/API.md`
|
|
1432
|
+
|
|
1433
|
+
- [ ] **Step 1: printUsage 更新**
|
|
1434
|
+
|
|
1435
|
+
- 命令区加:`min-agent chat/code --provider <name> Use a specific provider`
|
|
1436
|
+
- 选项区加:
|
|
1437
|
+
|
|
1438
|
+
```
|
|
1439
|
+
--provider <name> Use a specific configured provider
|
|
1440
|
+
```
|
|
1441
|
+
|
|
1442
|
+
- setup 说明行更新为 `min-agent setup Configure providers (interactive)`
|
|
1443
|
+
|
|
1444
|
+
- [ ] **Step 2: slash-commands.ts 菜单**
|
|
1445
|
+
|
|
1446
|
+
在 `{ name: "model", ... }` 之后加:
|
|
1447
|
+
|
|
1448
|
+
```ts
|
|
1449
|
+
{ name: "provider", description: "查看/切换 Provider", argHint: "[name]" },
|
|
1450
|
+
```
|
|
1451
|
+
|
|
1452
|
+
在 `{ name: "tokens", ... }` 之后加:
|
|
1453
|
+
|
|
1454
|
+
```ts
|
|
1455
|
+
{ name: "budget", description: "查看/设置预算上限", argHint: "[USD]" },
|
|
1456
|
+
{ name: "plan", description: "切换只读计划模式" },
|
|
1457
|
+
```
|
|
1458
|
+
|
|
1459
|
+
- [ ] **Step 3: /help 同步**(tui-chat.ts 的 help 数组)
|
|
1460
|
+
|
|
1461
|
+
```ts
|
|
1462
|
+
" /model [n] 交互式选择/切换模型",
|
|
1463
|
+
" /provider [n] 查看/切换 Provider",
|
|
1464
|
+
" /budget [n] 查看/设置预算上限",
|
|
1465
|
+
" /plan 切换只读计划模式",
|
|
1466
|
+
```
|
|
1467
|
+
|
|
1468
|
+
(替换原 `/model [n]` 行并在 `/tokens` 前插入 budget/plan 相关行,保持列表顺序清晰)
|
|
1469
|
+
|
|
1470
|
+
- [ ] **Step 4: README.md**
|
|
1471
|
+
|
|
1472
|
+
**Configuration 区**的 config.json 示例替换为:
|
|
1473
|
+
|
|
1474
|
+
```json
|
|
1475
|
+
// ~/.min-agent/config.json
|
|
1476
|
+
{
|
|
1477
|
+
"providers": [
|
|
1478
|
+
{
|
|
1479
|
+
"name": "default",
|
|
1480
|
+
"type": "openai-compatible",
|
|
1481
|
+
"baseURL": "https://api.openai.com/v1",
|
|
1482
|
+
"apiKey": "sk-...",
|
|
1483
|
+
"defaultModel": "gpt-5.5",
|
|
1484
|
+
"contextWindow": 128000
|
|
1485
|
+
}
|
|
1486
|
+
],
|
|
1487
|
+
"activeProvider": "default",
|
|
1488
|
+
"instructions": ["./docs/rules.md"],
|
|
1489
|
+
"disabledSkills": [],
|
|
1490
|
+
"pricing": { "gpt-5.5": { "inputPerMillion": 1.25, "outputPerMillion": 10 } },
|
|
1491
|
+
"sampling": { "temperature": 0.7, "maxTokens": 4096 },
|
|
1492
|
+
"budget": { "maxCostUSD": 5 }
|
|
1493
|
+
}
|
|
1494
|
+
```
|
|
1495
|
+
|
|
1496
|
+
说明文字更新:
|
|
1497
|
+
|
|
1498
|
+
```markdown
|
|
1499
|
+
Multiple providers are supported: add entries to `providers` and set `activeProvider` (or use `min-agent setup`). Old single-provider configs are migrated automatically on first run.
|
|
1500
|
+
|
|
1501
|
+
`sampling` sets defaults for temperature/maxTokens/topP (overridable per request). `budget.maxCostUSD` stops cost escalation beyond the limit (check after each turn).
|
|
1502
|
+
```
|
|
1503
|
+
|
|
1504
|
+
**Commands 区**加:
|
|
1505
|
+
|
|
1506
|
+
```markdown
|
|
1507
|
+
min-agent chat --provider <name> "msg" # Use a specific provider
|
|
1508
|
+
```
|
|
1509
|
+
|
|
1510
|
+
**Interactive Commands 表**加:
|
|
1511
|
+
|
|
1512
|
+
```markdown
|
|
1513
|
+
| `/provider [name]` | List/switch provider |
|
|
1514
|
+
| `/budget [USD]` | Show/set cost budget |
|
|
1515
|
+
| `/plan` | Toggle read-only plan mode |
|
|
1516
|
+
```
|
|
1517
|
+
|
|
1518
|
+
**日志说明**(Environment Variables 区之前或 Configuration 区末尾):
|
|
1519
|
+
|
|
1520
|
+
```markdown
|
|
1521
|
+
Logs: `~/.min-agent/logs/min-agent.YYYY-MM-DD.log` (rotated daily, kept 7 days)
|
|
1522
|
+
```
|
|
1523
|
+
|
|
1524
|
+
- [ ] **Step 5: docs/API.md**
|
|
1525
|
+
|
|
1526
|
+
**`POST /v1/chat` 字段表**加:
|
|
1527
|
+
|
|
1528
|
+
```markdown
|
|
1529
|
+
| `provider` | string | 使用指定 Provider(默认 activeProvider) |
|
|
1530
|
+
```
|
|
1531
|
+
|
|
1532
|
+
**done 事件字段表**的 done 行补:
|
|
1533
|
+
|
|
1534
|
+
```markdown
|
|
1535
|
+
| `done` | step_count, usage, has_error, aborted, max_steps_reached, budget_exceeded, messages, session_id | 结束 |
|
|
1536
|
+
```
|
|
1537
|
+
|
|
1538
|
+
- [ ] **Step 6: 提交**
|
|
1539
|
+
|
|
1540
|
+
```bash
|
|
1541
|
+
git add src/cli.ts src/tui/slash-commands.ts src/tui-chat.ts README.md docs/API.md
|
|
1542
|
+
git commit -m "docs: 第三批配置与命令文档同步"
|
|
1543
|
+
```
|
|
1544
|
+
|
|
1545
|
+
---
|
|
1546
|
+
|
|
1547
|
+
## Task 10: 全量验证 + 冒烟
|
|
1548
|
+
|
|
1549
|
+
- [ ] **Step 1: 类型检查**
|
|
1550
|
+
|
|
1551
|
+
Run: `bun run typecheck`(仓库根)
|
|
1552
|
+
Expected: 无错误
|
|
1553
|
+
|
|
1554
|
+
- [ ] **Step 2: 全量测试**
|
|
1555
|
+
|
|
1556
|
+
Run: `bun test`
|
|
1557
|
+
Expected: 全部通过
|
|
1558
|
+
|
|
1559
|
+
- [ ] **Step 3: 手动冒烟**
|
|
1560
|
+
|
|
1561
|
+
Run(packages/min-agent 下):
|
|
1562
|
+
```bash
|
|
1563
|
+
bun run src/cli.ts --help
|
|
1564
|
+
bun run src/cli.ts chat --provider no-such "hi" # 应报 Provider not found exit 1
|
|
1565
|
+
bun run src/cli.ts models # 显示 activeProvider 模型
|
|
1566
|
+
bun run src/cli.ts setup # 交互流程(可 Ctrl+C 退出)
|
|
1567
|
+
```
|
|
1568
|
+
|
|
1569
|
+
TUI 冒烟(`bun run src/cli.ts chat`):
|
|
1570
|
+
1. `/provider` 列出 providers
|
|
1571
|
+
2. `/plan` 进入计划模式(提示只读)→ `/plan` 退出
|
|
1572
|
+
3. `/budget` 显示预算状态 → `/budget 10` 设置
|
|
1573
|
+
4. `/help` 包含新命令
|
|
1574
|
+
|
|
1575
|
+
- [ ] **Step 4: 提交(如有遗留改动)**
|
|
1576
|
+
|
|
1577
|
+
```bash
|
|
1578
|
+
git add -A
|
|
1579
|
+
git commit -m "chore: 第三批验证收尾"
|
|
1580
|
+
```
|
|
1581
|
+
|
|
1582
|
+
---
|
|
1583
|
+
|
|
1584
|
+
## 自审记录
|
|
1585
|
+
|
|
1586
|
+
**Spec 覆盖:**
|
|
1587
|
+
- 多 provider(结构/迁移/CLI/TUI/serve)→ Task 1、2、3、4
|
|
1588
|
+
- sampling 配置 → Task 5
|
|
1589
|
+
- 预算上限 → Task 5
|
|
1590
|
+
- 日志系统 → Task 6
|
|
1591
|
+
- CI → Task 7
|
|
1592
|
+
- /plan → Task 8
|
|
1593
|
+
- 文档完善 → Task 9
|
|
1594
|
+
|
|
1595
|
+
**类型一致性:** `ProviderConfig`(含 name)在 Task 1 定义,Task 2/3/4 使用;`RunOptions` 的 providerName/planMode 在 Task 3 定义,Task 5/8 使用;`onRunFinish` info 的 budgetExceeded 在 Task 5 定义并被 tui-chat/serve 消费。
|