-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathdemo.ts
More file actions
251 lines (228 loc) · 10.4 KB
/
Copy pathdemo.ts
File metadata and controls
251 lines (228 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
#!/usr/bin/env node
/**
* JevLoop · demo
*
* node --experimental-strip-types examples/demo.ts
* node --experimental-strip-types examples/demo.ts --laya # 用本地 Laya
* node --experimental-strip-types examples/demo.ts --jev # 用官方 Jev(需 TYPESAFE_API_KEY)
*
* 零依赖、零 key、离线可跑 —— 默认用 Mock 后端把 loop 走通。
*
* 重点看最后那行汇总:**判定 : 模型** 的比值。
*
* @module JevLoop/demo
*/
import { mkdir, writeFile, rm, mkdtemp } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Decider, Meter, runAgent, loadEnv, resolveProvider, resolveGenerator, formatRatio } from '../src/index.ts'
import { RuleJudge } from './rule-judge.ts'
// 先加载 .env(有 TYPESAFE_API_KEY 就会自动用官方 Jev)
const env = loadEnv()
const argv = process.argv.slice(2)
const has = (f: string) => argv.includes(`--${f}`)
const prefer: 'jev' | 'laya' | 'mock' | 'rule' | 'scripted' | undefined =
(['jev', 'laya', 'mock', 'rule', 'scripted'] as const).find(has)
const C = {
dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
magenta: (s: string) => `\x1b[35m${s}\x1b[0m`,
}
// ── 造一个工作目录 ───────────────────────────────────────────
const cwd = await mkdtemp(join(tmpdir(), 'JevLoop-'))
await mkdir(cwd, { recursive: true })
await writeFile(
join(cwd, 'invoice.ts'),
`export interface Invoice {
id: string
amount: number
paid: boolean
}
/** Total amount still unpaid */
export function outstanding(invoices: Invoice[]): number {
return invoices
.filter((i) => !i.paid)
.reduce((sum, i) => sum + i.amount, 0)
}
`,
'utf8',
)
await writeFile(join(cwd, 'notes.md'), '# Notes\n\nA demo directory.\n', 'utf8')
// ── 组装 ─────────────────────────────────────────────────────
// 降级只提示一次 —— 每次判定都打一遍会把 trace 淹掉。
//
// ⚠️ 但「一次」是按**每一个转换**算的,不是一个全局标志位。重试也走这个回调
// (`from === to`),所以一个全局 flag 会让**先到的重试把后面真正的降级吃掉** ——
// 而降级是这两件事里更重要的那个。
const notified = new Set<string>()
const notice = (err: unknown, from: string, to: string) => {
const key = `${from}→${to}`
if (notified.has(key)) return
notified.add(key)
const why = (err as Error).message.slice(0, 60)
console.error(
from === to
? ` ▲ ${from}: ${why}` // 同一个后端再试一次,不是降级
: ` ▲ ${from} unavailable (${why}), falling back to ${to}`,
)
}
// 判定后端:--rule 强制规则表;--jev / --laya 强制指定;否则按可用性自动解析
const provider =
prefer === 'rule'
? new RuleJudge()
: resolveProvider({
...(prefer === 'jev' || prefer === 'laya' ? { prefer } : {}),
...(process.env.JEVOS_SIDECAR ? { layaUrl: process.env.JEVOS_SIDECAR } : {}),
// ★ 链尾用规则表,不用 Mock。
// Mock 的保守答案会让 pickTool 走到 escalate —— 全新 clone 上
// `npm run demo` 会在第一步停下,而 README 承诺「无 key 也能跑」。
// 规则表属于 examples(§8.6),所以在这里注入而不是写进内核。
lastResort: new RuleJudge(),
onFallback: notice,
})
const meter = new Meter()
const decider = new Decider({
provider,
meter,
// ★ 这两个选项以前**没有任何调用方**(`grep -rn "onWarn\|strict" examples/ server.ts`
// 零命中)—— 于是预算校验只在 `decide()` 里算一遍就丢掉,算了没人看等于没算。
// 这也是 C1(`headBudget` 死字段)修好之后仍然不生效的原因:只做 C1 不做这条,
// `error` 级别也只是一行没人读的字符串。
onWarn: (id, warnings) => {
for (const w of warnings) console.log(C.yellow(` ⚠ budget [${id}] ${w.message}`))
for (const w of warnings) if (w.hint) console.log(C.dim(` ${w.hint}`))
},
// --strict:预算的 error 级别直接抛,在**发请求之前**拦住
strict: process.argv.includes('--strict'),
})
const generator = resolveGenerator({ scripted: prefer === 'scripted' })
const TASK = 'List the files in the working directory, read the TypeScript file, and explain which functions it defines.'
console.log(C.bold('\nJevLoop · demo'))
console.log(C.dim(` task : ${TASK}`))
console.log(C.dim(` cwd : ${cwd}`))
console.log(C.dim(` decision : ${provider.name}`))
console.log(C.dim(` generator : ${generator.name}${generator.name === 'scripted' ? ' — set DEEPSEEK_API_KEY for a real LLM' : ''}`))
if (env.loaded.length) console.log(C.dim(` .env : loaded ${env.loaded.join(', ')}`))
// 认不出来的行进 `skipped`,**必须显示** —— 一行 `.env` 写错就悄悄退回 Mock 的话,
// 排查方向会被完全带偏(以前 `export KEY=VALUE` 就是这个下场)。
if (env.skipped.length) {
console.log(C.yellow(` .env : ⚠ skipped ${env.skipped.length} unrecognised line(s)`))
for (const line of env.skipped) console.log(C.dim(` ${line}`))
}
console.log('')
console.log(C.bold(' ── loop trace ──────────────────────────────────────────'))
/**
* 把生成的回答**边生成边打到终端**。
*
* ── 为什么不回退去擦掉上一版 ──────────────────────────────────
*
* 重试会**重新发起**生成(`reset: true`),而已经打出去的那半句在终端里
* 收不回来 —— 真要收得按行数算光标位置往上抹,而回答一动行数就变,
* 抹错地方比不抹更糟。
*
* 所以这里打一行说明再接着打:**「重来了一遍」这件事本身要看得到**,
* 而不是让两次尝试的文字在屏幕上首尾相接、看起来像一句模型从没说过的话
* (§8.10 不假装成功)。
*
* `chars` 是**收到过多少增量**,用来判断这次到底流没流 —— 后端不理睬
* `stream` 时一个增量都不会来,那时答案只能从 `run:end` 那份拿。
*/
function answerStream() {
const INDENT = ' '
let started = false
let lineStart = true
let chars = 0
return {
get chars() {
return chars
},
onDelta(d: { text: string; reset: boolean }): void {
if (d.reset) {
// 第一次进来是正常的开头;之后再进来就是重试
if (started) console.log(C.dim('\n ⟲ 上一次生成作废,重新生成…'))
else {
console.log('')
console.log(C.bold(' ── answer ──────────────────────────────────────────────'))
console.log('')
}
started = true
lineStart = true
chars = 0
return
}
// 逐字符走是为了**每行开头补缩进**(增量切在哪里和行边界无关)。
// `for…of` 按码点迭代,中文和 emoji 都不会被切成半个。
let out = ''
for (const ch of d.text) {
if (lineStart) {
out += INDENT
lineStart = false
}
out += ch
if (ch === '\n') lineStart = true
}
process.stdout.write(out)
chars += d.text.length
},
/** 收尾:最后一行没有换行的话补一个,免得后面的输出接在它屁股上 */
end(): void {
if (started && !lineStart) process.stdout.write('\n')
},
}
}
const stream = answerStream()
const result = await runAgent({
task: TASK,
cwd,
decider,
generator,
maxSteps: 8,
onTrace: (line) => console.log(C.dim(line)),
onDelta: (d) => stream.onDelta(d),
})
stream.end()
// ── 输出 ─────────────────────────────────────────────────────
console.log('')
console.log(C.bold(' ── every decision ──────────────────────────────────────'))
console.log(C.dim(meter.trace()))
console.log('')
console.log(C.bold(' ── result ──────────────────────────────────────────────'))
console.log(` halt : ${C.cyan(result.halt)}`)
console.log(` steps : ${result.steps}`)
console.log('')
/*
★ 答案通常**已经在上面流出来了**,这里不再抄一遍。
两种情况下仍然要打:后端没流式(`stream.chars === 0`),或者答案和流出来的
不一致 —— 后者意味着两者之间有一道没人预期的缝,那比重复一遍严重得多,
所以要说出来而不是找个好看的写法盖过去。
*/
if (stream.chars === 0) {
console.log(C.dim(' ' + result.answer.split('\n').join('\n ').slice(0, 600)))
} else if (stream.chars !== result.answer.length) {
console.log(
C.yellow(` ⚠ 流式收到 ${stream.chars} 字,最终答案是 ${result.answer.length} 字 —— 两者本该相同`),
)
console.log(C.dim(' ' + result.answer.split('\n').join('\n ').slice(0, 600)))
} else {
console.log(C.dim(` (回答见上,${result.answer.length} 字 —— 它是边生成边打出来的)`))
}
const s = meter.stats
console.log('')
console.log(C.bold(' ── accounting ──────────────────────────────────────────'))
console.log(
` decisions ${C.green(String(s.decisions).padStart(3))} ${C.dim(`${s.decisionMs}ms (${s.avgDecisionMs}ms each)`)}`,
)
console.log(
` model ${C.magenta(String(s.modelCalls).padStart(3))} ${C.dim(`${s.modelMs}ms`)}`,
)
console.log('')
console.log(
// 走 meter 的统一出口。以前这里自己拼,0 次模型调用时会报成 `3 : 1`(真相是 3:0)。
` ${C.bold('decisions : model =')} ${C.bold(C.green(formatRatio(s)))}` +
C.dim(` decisions are ${(s.decisionShare * 100).toFixed(1)}% of wall clock`),
)
console.log('')
await rm(cwd, { recursive: true, force: true })