@miphamai/cli 0.85.3 → 0.85.5
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/package.json +1 -1
- package/src/agent-view/agents-standalone.tsx +42 -0
- package/src/commands/project.ts +75 -24
- package/src/core/context.ts +56 -5
- package/src/core/engine.ts +95 -34
- package/src/core/hooks-executor.ts +88 -11
- package/src/core/hooks.ts +26 -2
- package/src/core/instructions.ts +105 -17
- package/src/core/permission-classifier.ts +21 -3
- package/src/core/permission-rules.ts +1 -1
- package/src/core/permission.ts +50 -1
- package/src/core/session-log.ts +60 -0
- package/src/daemon/index.ts +2 -5
- package/src/daemon/server.ts +19 -14
- package/src/i18n-core/locales/en-US.json +5 -2
- package/src/i18n-core/locales/zh-CN.json +5 -2
- package/src/index.tsx +17 -7
- package/src/mcp/client.ts +61 -0
- package/src/mcp/instructions.ts +49 -0
- package/src/mcp/types.ts +7 -0
- package/src/plugin/claude-plugin.ts +12 -2
- package/src/plugin/plugin-loader.ts +32 -14
- package/src/plugin/plugin-manager.ts +16 -2
- package/src/plugin/plugin-validator.ts +183 -1
- package/src/providers/anthropic.ts +36 -15
- package/src/providers/fetch-utils.ts +53 -5
- package/src/security/dangerous-rm.ts +192 -0
- package/src/shared/constants.ts +18 -0
- package/src/shared/deleted-cwd.ts +46 -1
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +8 -0
- package/src/ui/command-picker.tsx +18 -10
- package/src/ui/commands.ts +1 -1
- package/src/ui/config-wizard.tsx +22 -19
- package/src/ui/picker.tsx +38 -27
- package/src/ui/use-key-state.ts +55 -0
- package/src/daemon/message-bus.ts +0 -84
package/package.json
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mipham agents` 独立面板 —— 从 `index.tsx` 里抽出来的那段 JSX,好让它可测。
|
|
3
|
+
*
|
|
4
|
+
* 抽取的理由是**可测性**:`index.tsx` 是进程入口(`render` + `waitUntilExit`),
|
|
5
|
+
* 它里面传下去的东西在测试里够不着;而这条路上的 Enter 落点曾经整个缺席
|
|
6
|
+
* —— 页脚广告 attach,落点是 `() => {}`。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React, { useState } from 'react'
|
|
10
|
+
|
|
11
|
+
import { AgentSessionView } from './session-view'
|
|
12
|
+
import { AgentViewDashboard } from './dashboard'
|
|
13
|
+
import type { AgentViewManager } from './agent-view-manager'
|
|
14
|
+
|
|
15
|
+
interface AgentsStandaloneProps {
|
|
16
|
+
manager: AgentViewManager
|
|
17
|
+
onExit: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function AgentsStandalone({ manager, onExit }: AgentsStandaloneProps) {
|
|
21
|
+
// 与 `app.tsx` 同形:attach 把会话交给只读视图,视图里的 Esc 再交还列表。
|
|
22
|
+
// 页脚无条件印着「Enter attach」—— 它必须有个落点,否则按键有反应而世界不变。
|
|
23
|
+
const [attachedSessionId, setAttachedSessionId] = useState<string | null>(null)
|
|
24
|
+
|
|
25
|
+
if (attachedSessionId) {
|
|
26
|
+
return (
|
|
27
|
+
<AgentSessionView
|
|
28
|
+
manager={manager}
|
|
29
|
+
sessionId={attachedSessionId}
|
|
30
|
+
onDetach={() => setAttachedSessionId(null)}
|
|
31
|
+
/>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<AgentViewDashboard
|
|
37
|
+
manager={manager}
|
|
38
|
+
onAttach={(session) => setAttachedSessionId(session.id)}
|
|
39
|
+
onExit={onExit}
|
|
40
|
+
/>
|
|
41
|
+
)
|
|
42
|
+
}
|
package/src/commands/project.ts
CHANGED
|
@@ -174,57 +174,108 @@ Run /setup for the full wizard, or /config to view current settings.`,
|
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Split the rule argument(s) out of the whitespace-split slash-command args.
|
|
179
|
+
*
|
|
180
|
+
* Every surface that advertises this command prints the rule **quoted** — the denial
|
|
181
|
+
* messages (`i18n-core/locales/{en-US,zh-CN}.json`), this command's usage line and its
|
|
182
|
+
* examples, and the `config.yml` sample — and the narrower form the message recommends
|
|
183
|
+
* (`Bash(npm test)`) carries a space. Args arrive split on whitespace with the quotes
|
|
184
|
+
* still in them, so the rule is re-joined and split on quotes here instead. Without
|
|
185
|
+
* this the command rejects the exact spelling it tells the user to type:
|
|
186
|
+
* `/permissions allow "Git"` → `Invalid rule ""Git"": not a single tool name.`
|
|
187
|
+
*/
|
|
188
|
+
function parseRuleArgs(args: string[]): { rules: string[]; unbalanced: boolean } {
|
|
189
|
+
const rules: string[] = []
|
|
190
|
+
let current = ''
|
|
191
|
+
let quote: string | null = null
|
|
192
|
+
|
|
193
|
+
for (const ch of args.join(' ')) {
|
|
194
|
+
if (quote) {
|
|
195
|
+
if (ch === quote) quote = null
|
|
196
|
+
else current += ch
|
|
197
|
+
} else if (ch === '"' || ch === "'") {
|
|
198
|
+
quote = ch
|
|
199
|
+
} else if (/\s/.test(ch)) {
|
|
200
|
+
if (current) rules.push(current)
|
|
201
|
+
current = ''
|
|
202
|
+
} else {
|
|
203
|
+
current += ch
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (current) rules.push(current)
|
|
207
|
+
|
|
208
|
+
return { rules, unbalanced: quote !== null }
|
|
209
|
+
}
|
|
210
|
+
|
|
177
211
|
const permissionsCmd: CommandHandler = async (ctx, args) => {
|
|
178
212
|
const c = ctx.engine.getContext()
|
|
179
213
|
const msgs = c.getMessages()
|
|
180
214
|
|
|
181
|
-
// ── Rule persistence: allow/deny/remove <rule
|
|
182
|
-
|
|
215
|
+
// ── Rule persistence: allow/deny/remove <rule>... [--user] ──
|
|
216
|
+
// `--user` is matched exactly: a rule fragment may legitimately begin with `--`
|
|
217
|
+
// (`Bash(--version)`), and dropping it as "a flag" would corrupt the rule.
|
|
218
|
+
const rest = args.filter((a) => a !== '--user')
|
|
183
219
|
const scope: 'project' | 'user' = args.includes('--user') ? 'user' : 'project'
|
|
184
|
-
const verb =
|
|
185
|
-
const rule = positional[1]
|
|
220
|
+
const verb = rest[0]
|
|
186
221
|
|
|
187
222
|
if (verb === 'allow' || verb === 'deny' || verb === 'remove') {
|
|
188
223
|
const { validateRulePattern } = await import('../core/permission-rules')
|
|
189
224
|
const { addSettingsRule, removeSettingsRule, settingsPathFor } =
|
|
190
225
|
await import('../config/loader')
|
|
191
226
|
|
|
192
|
-
|
|
193
|
-
// that isn't there. Validate before writing.
|
|
194
|
-
const invalid = validateRulePattern(rule ?? '')
|
|
227
|
+
const { rules, unbalanced } = parseRuleArgs(rest.slice(1))
|
|
195
228
|
const usage =
|
|
196
|
-
`Usage: /permissions <allow|deny|remove> <rule
|
|
197
|
-
` rule Tool pattern — "Bash" or "Bash(npm test)".\n` +
|
|
229
|
+
`Usage: /permissions <allow|deny|remove> <rule>... [--user]\n\n` +
|
|
230
|
+
` rule Tool pattern — "Bash" or "Bash(npm test)". Quote it if it has spaces.\n` +
|
|
198
231
|
` --user Write to ~/.mipham/settings.json instead of .mipham/settings.json.`
|
|
199
232
|
|
|
200
|
-
if (
|
|
233
|
+
if (unbalanced) {
|
|
234
|
+
return { content: `Unbalanced quote in rule.\n\n${usage}` }
|
|
235
|
+
}
|
|
236
|
+
if (rules.length === 0) {
|
|
201
237
|
return { content: `Missing rule.\n\n${usage}` }
|
|
202
238
|
}
|
|
203
|
-
|
|
204
|
-
|
|
239
|
+
// A rule that can't match is worse than no rule: it reads as protection
|
|
240
|
+
// that isn't there. Validate every rule before writing any of them.
|
|
241
|
+
if (verb !== 'remove') {
|
|
242
|
+
for (const rule of rules) {
|
|
243
|
+
const invalid = validateRulePattern(rule)
|
|
244
|
+
if (invalid) {
|
|
245
|
+
return { content: `Invalid rule "${rule}": ${invalid}.\n\n${usage}` }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
205
248
|
}
|
|
206
249
|
|
|
207
250
|
const perm = ctx.engine.getPermission()
|
|
208
251
|
|
|
209
252
|
if (verb === 'remove') {
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
253
|
+
const parts: string[] = []
|
|
254
|
+
for (const rule of rules) {
|
|
255
|
+
const removed = removeSettingsRule(rule, scope)
|
|
256
|
+
if (!removed) {
|
|
257
|
+
parts.push(`No rule "${rule}" in ${settingsPathFor(scope)}.`)
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
perm.removeRule(rule)
|
|
261
|
+
parts.push(`Removed from ${removed.path}\n\npermissions.${removed.key}:\n ${rule}`)
|
|
217
262
|
}
|
|
263
|
+
return { content: parts.join('\n\n') }
|
|
218
264
|
}
|
|
219
265
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
266
|
+
let path = ''
|
|
267
|
+
for (const rule of rules) {
|
|
268
|
+
path = addSettingsRule(verb, rule, scope)
|
|
269
|
+
if (verb === 'allow') perm.allow(rule)
|
|
270
|
+
else perm.deny(rule)
|
|
271
|
+
}
|
|
223
272
|
return {
|
|
224
273
|
content:
|
|
225
274
|
`Added to ${path}\n\n` +
|
|
226
|
-
`permissions.${verb}:\n ${
|
|
227
|
-
|
|
275
|
+
`permissions.${verb}:\n${rules.map((r) => ` ${r}`).join('\n')}\n\n` +
|
|
276
|
+
(rules.length === 1
|
|
277
|
+
? `This rule persists across sessions and applies from now on.`
|
|
278
|
+
: `These rules persist across sessions and apply from now on.`),
|
|
228
279
|
}
|
|
229
280
|
}
|
|
230
281
|
|
package/src/core/context.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
deriveMessages,
|
|
9
9
|
assertModelVisible,
|
|
10
10
|
isAssertModelVisibleDebug,
|
|
11
|
+
closeInterruptedToolCalls,
|
|
11
12
|
} from './session-log'
|
|
12
13
|
|
|
13
14
|
export type Summarizer = (messages: Message[], heading: string) => Promise<string>
|
|
@@ -45,6 +46,12 @@ export class ContextManager {
|
|
|
45
46
|
* `index.tsx` 把它接到 live `PermissionSystem.getMode()` 上,于是切一次档,下一次请求就变。
|
|
46
47
|
*/
|
|
47
48
|
private permissionContextSource: (() => string) | null = null
|
|
49
|
+
/**
|
|
50
|
+
* 系统提示里的 **MCP instructions 段**同样是读时派生的,理由比权限段更硬:
|
|
51
|
+
* MCP server 是**启动后异步连上**的,而提示在 `setSystemPrompt()` 那一刻就建好了。
|
|
52
|
+
* 组装时烘进去的话,本次会话里后连上的 server 永远进不了提示 —— 用户只能重启。
|
|
53
|
+
*/
|
|
54
|
+
private mcpInstructionsSource: (() => string) | null = null
|
|
48
55
|
private estimatedTokens = 0
|
|
49
56
|
private checkpoints: Checkpoint[] = []
|
|
50
57
|
private checkpointCounter = 0
|
|
@@ -79,6 +86,12 @@ export class ContextManager {
|
|
|
79
86
|
/** 从已持久化的日志恢复:设 log 为源,messages 为投影(不重复写通)。 */
|
|
80
87
|
restoreLog(log: SessionLog): void {
|
|
81
88
|
this.log = log
|
|
89
|
+
// 盘上那份历史可能以一条**没有结果**的调用收尾(写盘写到一半被打断,末尾那行半截
|
|
90
|
+
// 结果被 `open()` 静默丢掉 —— 引擎自己的日志顺序到不了这个形状,见下面那个函数的
|
|
91
|
+
// 注释)。挂着 `tool_calls` 却没有结果回应的请求会被 provider 整条拒收,用户看到的
|
|
92
|
+
// 是「恢复之后第一句话就报协议错」。恢复的这一刻把它补**进日志**(不是补进投影,
|
|
93
|
+
// 否则「模型看得见的必须已记录」这条不变量当场破),见 `closeInterruptedToolCalls`。
|
|
94
|
+
closeInterruptedToolCalls(log)
|
|
82
95
|
this.messages = deriveMessages(log.events())
|
|
83
96
|
this.reEstimateTokens()
|
|
84
97
|
}
|
|
@@ -138,14 +151,28 @@ export class ContextManager {
|
|
|
138
151
|
}
|
|
139
152
|
|
|
140
153
|
/**
|
|
141
|
-
*
|
|
154
|
+
* 接线点(`index.tsx`):把已连 MCP server 自带的 `instructions` 接到提示上。
|
|
142
155
|
*
|
|
143
|
-
*
|
|
144
|
-
|
|
156
|
+
* 传 `null` 撤销。空串(无 server / 都没写 instructions)不产生空段。
|
|
157
|
+
*/
|
|
158
|
+
setMcpInstructionsSource(fn: (() => string) | null): void {
|
|
159
|
+
this.mcpInstructionsSource = fn
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 存储的提示 + 读时派生的段(权限 / MCP instructions)。
|
|
164
|
+
*
|
|
165
|
+
* 段尾追加(而非插回原来的中段位置)是刻意的:只切档、只连一个新 server 时
|
|
166
|
+
* **前缀保持不变**,提供方的 prefix cache 仍能命中到这些段之前的部分。
|
|
145
167
|
*/
|
|
146
168
|
private composedSystemPrompt(): string {
|
|
147
|
-
const
|
|
148
|
-
|
|
169
|
+
const blocks = [
|
|
170
|
+
this.permissionContextSource?.() ?? '',
|
|
171
|
+
this.mcpInstructionsSource?.() ?? '',
|
|
172
|
+
].filter((b) => b !== '')
|
|
173
|
+
return blocks.length > 0
|
|
174
|
+
? [this.systemPrompt, ...blocks].join('\n\n---\n\n')
|
|
175
|
+
: this.systemPrompt
|
|
149
176
|
}
|
|
150
177
|
|
|
151
178
|
getSystemPrompt(): string {
|
|
@@ -170,6 +197,30 @@ export class ContextManager {
|
|
|
170
197
|
this.checkCompression()
|
|
171
198
|
}
|
|
172
199
|
|
|
200
|
+
/**
|
|
201
|
+
* 注入一段**模型可见的上下文**(规则块 / compact 前后的 hook 提示 / stop hook 交回的话 /
|
|
202
|
+
* 后台 agent 的来件)。
|
|
203
|
+
*
|
|
204
|
+
* 为什么不复用 addMessage:那会把注入记成 `user/message`,在日志里与**用户真说过的话**
|
|
205
|
+
* 完全同形 —— 事后翻 session log 分不出哪一句是用户敲的、哪一句是我们塞进去的。记成
|
|
206
|
+
* `context/inject`(带 `source`)就带得出来源;而 `deriveMessages` 仍把它还原成同一个
|
|
207
|
+
* user 消息,所以**投影逐字节不变**,「model-visible means logged」照样成立。
|
|
208
|
+
*/
|
|
209
|
+
injectContext(source: string, text: string): void {
|
|
210
|
+
this.messages.push({ role: 'user', content: text })
|
|
211
|
+
this.estimatedTokens += this.estimateTokens(text)
|
|
212
|
+
|
|
213
|
+
if (this.log) {
|
|
214
|
+
this.log.append({ type: 'context/inject', at: Date.now(), source, text })
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (this.log && isAssertModelVisibleDebug()) {
|
|
218
|
+
assertModelVisible(this.log.events(), this.messages)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.checkCompression()
|
|
222
|
+
}
|
|
223
|
+
|
|
173
224
|
/** 记录工具执行结果(全量 ToolResult 含 success/error)到日志,并写投影消息(不重复走 messageToEvents 拆分)。 */
|
|
174
225
|
addToolResult(toolUseId: string, result: ToolResult): void {
|
|
175
226
|
const content = result.success ? result.content : result.error || result.content
|
package/src/core/engine.ts
CHANGED
|
@@ -43,6 +43,7 @@ import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './infe
|
|
|
43
43
|
import { getFileInboxTransport } from '../agent/cross-session/file-inbox'
|
|
44
44
|
import { registerWakeupHandler } from '../tools/scheduling/schedule-wakeup'
|
|
45
45
|
import { accumulateGraftSavings } from '../shared/graft-savings'
|
|
46
|
+
import { deletedCwdSessionMessage, resolveExistingCwd } from '../shared/deleted-cwd'
|
|
46
47
|
import {
|
|
47
48
|
getMessageBus,
|
|
48
49
|
formatInboundMessage,
|
|
@@ -60,6 +61,18 @@ const bundles: Record<string, TranslationMap> = {
|
|
|
60
61
|
}
|
|
61
62
|
const t = createT(bundles['en-US'] || (enUS as TranslationMap), enUS as TranslationMap)
|
|
62
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Tool-calling rounds one **turn** may spend, however it spends them. 20 was too
|
|
66
|
+
* low for real multi-step tasks — the model hit "max turns" and dropped pending
|
|
67
|
+
* tools mid-task. 100 stays bounded.
|
|
68
|
+
*
|
|
69
|
+
* A budget for the turn, not for one loop: a blocking Stop hook buys another round
|
|
70
|
+
* out of the same pool. The earlier shape re-granted a fresh 100 per loop
|
|
71
|
+
* invocation, so the cap bounded no turn at all — a hook that always asked for more
|
|
72
|
+
* kept the turn running until the process died.
|
|
73
|
+
*/
|
|
74
|
+
const MAX_TOOL_TURNS = 100
|
|
75
|
+
|
|
63
76
|
/**
|
|
64
77
|
* Drop inbound messages whose timestamp is older than the dialog-expiry TTL.
|
|
65
78
|
* A stale message's approval dialog is no longer relevant, so it's discarded
|
|
@@ -296,7 +309,7 @@ export class QueryEngine {
|
|
|
296
309
|
let injected = 0
|
|
297
310
|
for (const recipient of recipients) {
|
|
298
311
|
for (const msg of bus.poll(recipient)) {
|
|
299
|
-
this.context.
|
|
312
|
+
this.context.injectContext(`agent:${msg.from}`, formatInboundMessage(msg))
|
|
300
313
|
injected++
|
|
301
314
|
}
|
|
302
315
|
bus.markAllRead(recipient)
|
|
@@ -389,7 +402,7 @@ export class QueryEngine {
|
|
|
389
402
|
const files = Array.from(this.touchedFiles)
|
|
390
403
|
const block = this.rulesLoader.buildContextBlock(files)
|
|
391
404
|
if (!block) return
|
|
392
|
-
this.context.
|
|
405
|
+
this.context.injectContext('rules', block)
|
|
393
406
|
this.touchedFiles.clear()
|
|
394
407
|
}
|
|
395
408
|
|
|
@@ -789,14 +802,15 @@ export class QueryEngine {
|
|
|
789
802
|
yield chunk
|
|
790
803
|
}
|
|
791
804
|
|
|
792
|
-
// If tools were executed, recursively continue the conversation
|
|
805
|
+
// If tools were executed, recursively continue the conversation — with what
|
|
806
|
+
// is left of the turn's budget after the round just spent here.
|
|
793
807
|
if (toolUses.length > 0) {
|
|
794
|
-
yield* this.continueWithTools(signal)
|
|
808
|
+
yield* this.continueWithTools(signal, MAX_TOOL_TURNS - 1)
|
|
795
809
|
return
|
|
796
810
|
}
|
|
797
811
|
|
|
798
812
|
// Fire Stop hooks when AI finishes with no tool calls
|
|
799
|
-
yield* this.checkStopHook(signal)
|
|
813
|
+
yield* this.checkStopHook(signal, MAX_TOOL_TURNS - 1)
|
|
800
814
|
|
|
801
815
|
// Final drain of task notifications
|
|
802
816
|
for (const chunk of this.drainTaskNotifications()) {
|
|
@@ -883,17 +897,17 @@ export class QueryEngine {
|
|
|
883
897
|
}
|
|
884
898
|
}
|
|
885
899
|
|
|
886
|
-
private async *continueWithTools(
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
900
|
+
private async *continueWithTools(
|
|
901
|
+
signal?: AbortSignal,
|
|
902
|
+
roundsLeft: number = MAX_TOOL_TURNS,
|
|
903
|
+
): AsyncGenerator<StreamChunk> {
|
|
890
904
|
// Task-level stall guard: a turn that produces no text or tool result for
|
|
891
905
|
// this long is considered stalled and stopped (prevents ~40-min idle spins).
|
|
892
906
|
const TURN_TIMEOUT_MS = 15 * 60 * 1000
|
|
893
907
|
let lastActivity = Date.now()
|
|
894
908
|
const toolDefs = this.getToolDefinitions()
|
|
895
909
|
|
|
896
|
-
for (let turn = 0; turn <
|
|
910
|
+
for (let turn = 0; turn < roundsLeft; turn++) {
|
|
897
911
|
if (Date.now() - lastActivity > TURN_TIMEOUT_MS) {
|
|
898
912
|
yield {
|
|
899
913
|
type: 'warning',
|
|
@@ -1008,11 +1022,11 @@ export class QueryEngine {
|
|
|
1008
1022
|
}
|
|
1009
1023
|
|
|
1010
1024
|
// Safety: when max turns reached with pending tools, ask model to summarize
|
|
1011
|
-
if (turn ===
|
|
1025
|
+
if (turn === roundsLeft - 1 && toolUses.length > 0) {
|
|
1012
1026
|
this.context.addMessage({
|
|
1013
1027
|
role: 'user',
|
|
1014
1028
|
content: t('errors.max_tool_turns_warning', {
|
|
1015
|
-
max: String(
|
|
1029
|
+
max: String(MAX_TOOL_TURNS),
|
|
1016
1030
|
pending: String(toolUses.length),
|
|
1017
1031
|
}),
|
|
1018
1032
|
})
|
|
@@ -1033,7 +1047,7 @@ export class QueryEngine {
|
|
|
1033
1047
|
} catch {
|
|
1034
1048
|
yield {
|
|
1035
1049
|
type: 'error',
|
|
1036
|
-
error: t('errors.max_tool_turns', { max: String(
|
|
1050
|
+
error: t('errors.max_tool_turns', { max: String(MAX_TOOL_TURNS) }),
|
|
1037
1051
|
}
|
|
1038
1052
|
}
|
|
1039
1053
|
return
|
|
@@ -1041,7 +1055,7 @@ export class QueryEngine {
|
|
|
1041
1055
|
|
|
1042
1056
|
// No more tool calls — fire Stop hook and potentially continue
|
|
1043
1057
|
if (toolUses.length === 0) {
|
|
1044
|
-
yield* this.checkStopHook(signal)
|
|
1058
|
+
yield* this.checkStopHook(signal, roundsLeft - turn - 1)
|
|
1045
1059
|
return
|
|
1046
1060
|
}
|
|
1047
1061
|
|
|
@@ -1228,8 +1242,24 @@ export class QueryEngine {
|
|
|
1228
1242
|
}
|
|
1229
1243
|
|
|
1230
1244
|
try {
|
|
1245
|
+
// Every tool is handed the session's working directory, and that directory
|
|
1246
|
+
// can be deleted while the session runs. Nothing upstream notices: Node
|
|
1247
|
+
// throws from whichever call site touches it first, Bun keeps handing back
|
|
1248
|
+
// the path it cached at startup, and the tool then fails at its first
|
|
1249
|
+
// syscall naming whatever it touched (`spawn /bin/sh ENOENT` blames the
|
|
1250
|
+
// shell). Refuse here, where the reason is still known.
|
|
1251
|
+
const cwd = resolveExistingCwd()
|
|
1252
|
+
if (!cwd) {
|
|
1253
|
+
// Both fields carry the message on purpose: a failed result is projected
|
|
1254
|
+
// as `error || content` (the two tool-result loops and the context log all
|
|
1255
|
+
// read it that way), so guidance parked in `content` alone would be
|
|
1256
|
+
// dropped exactly when it is needed.
|
|
1257
|
+
const reason = deletedCwdSessionMessage()
|
|
1258
|
+
return { success: false, content: reason, error: reason }
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1231
1261
|
const result = await tool.execute(effectiveParams, {
|
|
1232
|
-
cwd
|
|
1262
|
+
cwd,
|
|
1233
1263
|
sessionId: this.sessionId,
|
|
1234
1264
|
provider: this.registry.getActive().config.id,
|
|
1235
1265
|
model: this.registry.getActiveModel(),
|
|
@@ -1373,9 +1403,21 @@ export class QueryEngine {
|
|
|
1373
1403
|
this.llm = llm
|
|
1374
1404
|
}
|
|
1375
1405
|
|
|
1376
|
-
/**
|
|
1406
|
+
/**
|
|
1407
|
+
* 统一 chat 出口:优先走注入的 Llm 缝,否则回退 registry。
|
|
1408
|
+
*
|
|
1409
|
+
* 被截断的一轮在这里点名。`truncated` 只有 provider 能判(被上限截断、连接
|
|
1410
|
+
* 被干净地关掉、正常写完,在字节流上是同一个形状),而这里是**每一处消费
|
|
1411
|
+
* chat 流的地方都必经的唯一出口** —— 转发的循环有两个(`process` 与
|
|
1412
|
+
* `continueWithTools`),各自补一次正是「两条渲染路径只接一条」的老毛病。
|
|
1413
|
+
*/
|
|
1377
1414
|
private async *llmChat(req: ChatRequest): AsyncGenerator<StreamChunk> {
|
|
1378
|
-
|
|
1415
|
+
for await (const chunk of (this.llm ?? this.registry).chat(req)) {
|
|
1416
|
+
if (chunk.type === 'stop' && chunk.truncated) {
|
|
1417
|
+
yield { type: 'warning', content: t('errors.turn_truncated') }
|
|
1418
|
+
}
|
|
1419
|
+
yield chunk
|
|
1420
|
+
}
|
|
1379
1421
|
}
|
|
1380
1422
|
|
|
1381
1423
|
/**
|
|
@@ -1494,13 +1536,15 @@ export class QueryEngine {
|
|
|
1494
1536
|
: t('errors.tool_denied_classifier', { name, reason })
|
|
1495
1537
|
}
|
|
1496
1538
|
|
|
1497
|
-
const { reason, rulePattern } = this.permission.explainDenial(tool, params)
|
|
1539
|
+
const { reason, rulePattern, target } = this.permission.explainDenial(tool, params)
|
|
1498
1540
|
const mode = this.permission.getMode()
|
|
1499
1541
|
switch (reason) {
|
|
1500
1542
|
case 'deny-rule':
|
|
1501
1543
|
return t('errors.tool_denied_deny_rule', { name, pattern: rulePattern ?? '?' })
|
|
1502
1544
|
case 'ask-rule':
|
|
1503
1545
|
return t('errors.tool_denied_ask_rule', { name, pattern: rulePattern ?? '?' })
|
|
1546
|
+
case 'dangerous-rm':
|
|
1547
|
+
return t('errors.tool_denied_dangerous_rm', { name, target: target ?? '?' })
|
|
1504
1548
|
default:
|
|
1505
1549
|
// mode-baseline / tool-default / legacy-rule / system-default — a mode
|
|
1506
1550
|
// switch (or /permissions) resolves it.
|
|
@@ -1685,10 +1729,10 @@ export class QueryEngine {
|
|
|
1685
1729
|
if (this.hookEngine) {
|
|
1686
1730
|
const preResult = await this.hookEngine.executePreCompact(this.sessionId)
|
|
1687
1731
|
if (preResult.additionalContext) {
|
|
1688
|
-
this.context.
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1732
|
+
this.context.injectContext(
|
|
1733
|
+
'pre-compact',
|
|
1734
|
+
`[Pre-compact context]: ${preResult.additionalContext}`,
|
|
1735
|
+
)
|
|
1692
1736
|
}
|
|
1693
1737
|
}
|
|
1694
1738
|
|
|
@@ -1697,29 +1741,46 @@ export class QueryEngine {
|
|
|
1697
1741
|
if (this.hookEngine) {
|
|
1698
1742
|
const postResult = await this.hookEngine.executePostCompact(this.sessionId)
|
|
1699
1743
|
if (postResult.additionalContext) {
|
|
1700
|
-
this.context.
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1744
|
+
this.context.injectContext(
|
|
1745
|
+
'post-compact',
|
|
1746
|
+
`[Post-compact context]: ${postResult.additionalContext}`,
|
|
1747
|
+
)
|
|
1704
1748
|
}
|
|
1705
1749
|
}
|
|
1706
1750
|
}
|
|
1707
1751
|
|
|
1708
1752
|
/** Fire Stop hook. If blocked, feed the reason back to the AI and continue. */
|
|
1709
|
-
private async *checkStopHook(
|
|
1753
|
+
private async *checkStopHook(
|
|
1754
|
+
signal?: AbortSignal,
|
|
1755
|
+
roundsLeft: number = MAX_TOOL_TURNS,
|
|
1756
|
+
): AsyncGenerator<StreamChunk> {
|
|
1710
1757
|
if (!this.hookEngine) return
|
|
1711
1758
|
|
|
1712
1759
|
const stopResult = await this.hookEngine.executeStop(this.sessionId)
|
|
1713
|
-
if (stopResult.decision
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1760
|
+
if (stopResult.decision !== 'block') return
|
|
1761
|
+
|
|
1762
|
+
// A block asks for more work, and more work means another round — which the
|
|
1763
|
+
// turn may have none of left. Saying so is the point: a hook quietly ignored
|
|
1764
|
+
// is worse than one told it has been overruled.
|
|
1765
|
+
if (roundsLeft <= 0) {
|
|
1766
|
+
yield {
|
|
1767
|
+
type: 'warning',
|
|
1768
|
+
content: t('errors.stop_hook_budget_spent', {
|
|
1769
|
+
max: String(MAX_TOOL_TURNS),
|
|
1718
1770
|
reason: stopResult.reason || 'Continue working.',
|
|
1719
1771
|
}),
|
|
1720
|
-
}
|
|
1721
|
-
|
|
1772
|
+
}
|
|
1773
|
+
return
|
|
1722
1774
|
}
|
|
1775
|
+
|
|
1776
|
+
// Feed the block reason back to the AI and continue
|
|
1777
|
+
this.context.injectContext(
|
|
1778
|
+
'stop-hook',
|
|
1779
|
+
t('system.context.stop_blocked', {
|
|
1780
|
+
reason: stopResult.reason || 'Continue working.',
|
|
1781
|
+
}),
|
|
1782
|
+
)
|
|
1783
|
+
yield* this.continueWithTools(signal, roundsLeft)
|
|
1723
1784
|
}
|
|
1724
1785
|
}
|
|
1725
1786
|
|