typeclaw 0.37.1 → 0.37.3
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 +3 -2
- package/package.json +2 -1
- package/src/agent/index.ts +9 -1
- package/src/agent/proactive-next-step-nudge.ts +11 -0
- package/src/agent/session-origin.ts +21 -0
- package/src/channels/continuation-willingness.ts +64 -0
- package/src/channels/router.ts +153 -17
- package/src/cli/fuzzy-filter.ts +32 -0
- package/src/cli/init.ts +6 -0
- package/src/cli/model.ts +2 -0
- package/src/cli/provider.ts +3 -0
- package/src/config/providers.ts +5 -2
package/README.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# TypeClaw
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
|
-
<img src="./docs/public/typeclaw.png" alt="TypeClaw logo" width="240" />
|
|
4
|
+
<img src="./docs/public/typeclaw-transparent.png" alt="TypeClaw logo" width="240" />
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
|
-
>
|
|
7
|
+
<h3 align="center">The agent for perfectionists</h3>
|
|
8
|
+
<p align="center">Crafted in every detail – it behaves in your team's chat and<br />gets sharper the longer it runs. Sandboxed and self-managing.</p>
|
|
8
9
|
|
|
9
10
|
## Why?
|
|
10
11
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typeclaw",
|
|
3
|
-
"version": "0.37.
|
|
3
|
+
"version": "0.37.3",
|
|
4
4
|
"homepage": "https://github.com/typeclaw/typeclaw#readme",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/typeclaw/typeclaw/issues"
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"format:check": "oxfmt --check .",
|
|
38
38
|
"check": "bun run typecheck && bun run lint && bun run format:check",
|
|
39
39
|
"test": "bun test --parallel",
|
|
40
|
+
"dev:docs": "cd docs && bun run dev",
|
|
40
41
|
"generate:schema": "bun run scripts/generate-schema.ts",
|
|
41
42
|
"debug:prompt": "bun run scripts/dump-system-prompt.ts",
|
|
42
43
|
"postinstall": "bun run scripts/generate-schema.ts"
|
package/src/agent/index.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { loadMemory } from '@/bundled-plugins/memory/load-memory'
|
|
|
15
15
|
import type { ChannelRouter } from '@/channels/router'
|
|
16
16
|
import type { ReactionRef } from '@/channels/types'
|
|
17
17
|
import { getConfig, resolveModel, resolveProfile } from '@/config'
|
|
18
|
-
import { defaultThinkingLevelForRef, providerForModelRef, type ModelRef } from '@/config/providers'
|
|
18
|
+
import { defaultThinkingLevelForRef, isOpenAiFamilyRef, providerForModelRef, type ModelRef } from '@/config/providers'
|
|
19
19
|
import { renderMcpCatalog } from '@/mcp/catalog'
|
|
20
20
|
import type { McpManager } from '@/mcp/manager'
|
|
21
21
|
import { createMcpDispatcherTools, MCP_DISPATCHER_TOOL_NAMES } from '@/mcp/tools'
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
wrapSystemTool,
|
|
48
48
|
zodToToolParameters,
|
|
49
49
|
} from './plugin-tools'
|
|
50
|
+
import { PROACTIVE_NEXT_STEP_NUDGE } from './proactive-next-step-nudge'
|
|
50
51
|
import { createReloadTool } from './reload-tool'
|
|
51
52
|
import type { RestartHandoffOrigin } from './restart-handoff'
|
|
52
53
|
import type { SubagentBashPolicy } from './reviewer-bash-policy'
|
|
@@ -277,6 +278,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
277
278
|
...(options.mcpManager !== undefined ? { mcpManager: options.mcpManager } : {}),
|
|
278
279
|
...(options.subagentRegistry !== undefined ? { subagentRegistry: options.subagentRegistry } : {}),
|
|
279
280
|
...(options.suppressSystemMemory !== undefined ? { suppressSystemMemory: options.suppressSystemMemory } : {}),
|
|
281
|
+
...(isOpenAiFamilyRef(activeRef) ? { proactiveNextStepNudge: true } : {}),
|
|
280
282
|
})
|
|
281
283
|
|
|
282
284
|
const getOrigin: () => SessionOrigin | undefined =
|
|
@@ -957,6 +959,7 @@ export type CreateResourceLoaderOptions = {
|
|
|
957
959
|
// from `memory.vector.enabled` — vector is restart-required, so the boot
|
|
958
960
|
// snapshot is coherent with the per-turn injection decision.
|
|
959
961
|
suppressSystemMemory?: boolean
|
|
962
|
+
proactiveNextStepNudge?: boolean
|
|
960
963
|
}
|
|
961
964
|
|
|
962
965
|
// Origins where the operator-facing DEFAULT_SYSTEM_PROMPT, git-nudge, and the
|
|
@@ -1020,6 +1023,7 @@ export type SystemPromptComposition = {
|
|
|
1020
1023
|
roleContext?: SessionRoleContext
|
|
1021
1024
|
mcpCatalog?: string
|
|
1022
1025
|
gitNudge: string
|
|
1026
|
+
proactiveNextStepNudge?: string
|
|
1023
1027
|
memorySection: string
|
|
1024
1028
|
}
|
|
1025
1029
|
|
|
@@ -1065,6 +1069,9 @@ export function composeSystemPrompt(parts: SystemPromptComposition): string {
|
|
|
1065
1069
|
if (parts.gitNudge !== '') {
|
|
1066
1070
|
prompt = `${prompt}\n\n${parts.gitNudge}`
|
|
1067
1071
|
}
|
|
1072
|
+
if (parts.proactiveNextStepNudge !== undefined && parts.proactiveNextStepNudge !== '') {
|
|
1073
|
+
prompt = `${prompt}\n\n${parts.proactiveNextStepNudge}`
|
|
1074
|
+
}
|
|
1068
1075
|
if (parts.memorySection !== '') {
|
|
1069
1076
|
prompt = `${prompt}\n\n${parts.memorySection}`
|
|
1070
1077
|
}
|
|
@@ -1164,6 +1171,7 @@ export async function createResourceLoader(options: CreateResourceLoaderOptions
|
|
|
1164
1171
|
? { mcpCatalog: renderMcpCatalog(options.mcpManager.listServers()) }
|
|
1165
1172
|
: {}),
|
|
1166
1173
|
gitNudge,
|
|
1174
|
+
...(options.proactiveNextStepNudge === true ? { proactiveNextStepNudge: PROACTIVE_NEXT_STEP_NUDGE } : {}),
|
|
1167
1175
|
memorySection,
|
|
1168
1176
|
})
|
|
1169
1177
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const PROACTIVE_NEXT_STEP_NUDGE_TITLE = '## Proactive and requested next-step guidance'
|
|
2
|
+
|
|
3
|
+
// GPT-only prompt text is intentionally absent from scripts/dump-system-prompt.ts
|
|
4
|
+
// token accounting because the dump tooling renders with non-GPT placeholders.
|
|
5
|
+
export const PROACTIVE_NEXT_STEP_NUDGE = [
|
|
6
|
+
PROACTIVE_NEXT_STEP_NUDGE_TITLE,
|
|
7
|
+
'',
|
|
8
|
+
'GPT/OpenAI-family behavior nudge: when the user asks for work and a reasonable or necessary next step is obvious, do not ask for permission or confirmation before doing it. Do the next step when it makes sense, especially when it is necessary to complete the task well. Avoid empty optional follow-up CTAs such as “if you want, I can also …”; either take the useful next action or end with the completed result.',
|
|
9
|
+
'',
|
|
10
|
+
'When the user explicitly asks for suggestions, options, alternatives, or what to do next, answer that request directly with concrete next-step suggestions instead of treating suggestions as an unwanted follow-up CTA.',
|
|
11
|
+
].join('\n')
|
|
@@ -384,6 +384,27 @@ function renderChannelOrigin(
|
|
|
384
384
|
)
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
+
// Discord renders no GFM tables — a raw `| a | b |` block shows as literal
|
|
388
|
+
// pipes. The discord-bot adapter rewrites BARE pipe tables into aligned
|
|
389
|
+
// inline-code rows for readability, but it skips any table inside a ``` /
|
|
390
|
+
// ~~~ fence (a fenced table is literal text by CommonMark). Models that have
|
|
391
|
+
// "learned" Discord mangles tables defensively wrap them in a fence, which is
|
|
392
|
+
// exactly what disables the auto-conversion — so the table renders ragged
|
|
393
|
+
// anyway. Tell the model to emit tables bare and let the adapter format them.
|
|
394
|
+
if (origin.adapter === 'discord-bot') {
|
|
395
|
+
lines.push(
|
|
396
|
+
'',
|
|
397
|
+
'**Emit Markdown tables as bare `| a | b |` blocks — never inside a code',
|
|
398
|
+
'fence.** Discord does not render Markdown tables, so this session',
|
|
399
|
+
'auto-reformats a bare pipe table (a `|`-row followed by a `|---|`',
|
|
400
|
+
'alignment row) into aligned, readable columns before it sends. That',
|
|
401
|
+
'reformatting only fires on raw Markdown: the moment you wrap the table in',
|
|
402
|
+
'a ``` or ~~~ fence it is treated as literal text and lands as ragged pipes.',
|
|
403
|
+
'So write the table directly in your reply with no surrounding fence. Use',
|
|
404
|
+
'fences only for actual code or output you want shown verbatim.',
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
387
408
|
const conversationLine = renderConversationLine(origin)
|
|
388
409
|
if (conversationLine !== null) lines.push('', conversationLine)
|
|
389
410
|
|
|
@@ -48,8 +48,20 @@ const EN_PHRASES: readonly string[] = [
|
|
|
48
48
|
'looking into it now',
|
|
49
49
|
'working on it now',
|
|
50
50
|
'on it now',
|
|
51
|
+
"i'm on it",
|
|
51
52
|
'give me a moment',
|
|
52
53
|
'give me a sec',
|
|
54
|
+
// Parity additions for common first-person-future acks: "investigate / look up
|
|
55
|
+
// / pull up" are work-verb siblings of the "look into / dig in" entries above,
|
|
56
|
+
// and "lemme" is the contracted "let me" that chat models routinely emit.
|
|
57
|
+
"i'll investigate",
|
|
58
|
+
"i'll look it up",
|
|
59
|
+
"i'll pull that up",
|
|
60
|
+
"i'll pull it up",
|
|
61
|
+
'let me pull',
|
|
62
|
+
'lemme check',
|
|
63
|
+
'lemme look',
|
|
64
|
+
'lemme take a look',
|
|
53
65
|
]
|
|
54
66
|
|
|
55
67
|
// Korean: -ㄹ게요 / -겠습니다 future-volitional endings on check/look/continue/
|
|
@@ -81,6 +93,27 @@ const KO_PHRASES: readonly string[] = [
|
|
|
81
93
|
'잠시만요',
|
|
82
94
|
'잠깐만요',
|
|
83
95
|
'곧 알려',
|
|
96
|
+
// Bare first-person-volitional verb endings: the -ㄹ게요/-겠습니다 ending is
|
|
97
|
+
// self-directed regardless of the preceding adverb, so the "바로 …" prefix in
|
|
98
|
+
// the entries above is not load-bearing. "볼게요" alone (and "먼저/한번/지금 볼게요"
|
|
99
|
+
// by substring) is the exact production miss — the ack "…먼저 볼게요" did not
|
|
100
|
+
// match because only the "바로 볼게요" compound was listed. Common work verbs
|
|
101
|
+
// (검토/조회/찾아/알아/처리) in the same volitional form join here for parity with
|
|
102
|
+
// "확인/살펴" above; "볼게여" is the casual -여 variant seen in chat.
|
|
103
|
+
'볼게요',
|
|
104
|
+
'볼게여',
|
|
105
|
+
'확인할게여',
|
|
106
|
+
'검토할게요',
|
|
107
|
+
'검토해볼게요',
|
|
108
|
+
'검토하겠습니다',
|
|
109
|
+
'조회해볼게요',
|
|
110
|
+
'조회하겠습니다',
|
|
111
|
+
'찾아볼게요',
|
|
112
|
+
'찾아보겠습니다',
|
|
113
|
+
'알아볼게요',
|
|
114
|
+
'알아보겠습니다',
|
|
115
|
+
'처리할게요',
|
|
116
|
+
'처리하겠습니다',
|
|
84
117
|
]
|
|
85
118
|
|
|
86
119
|
// The remaining languages mirror the precision-first selection above: every
|
|
@@ -106,6 +139,12 @@ const ES_PHRASES: readonly string[] = [
|
|
|
106
139
|
'déjame comprobar',
|
|
107
140
|
'déjame verificar',
|
|
108
141
|
'déjame mirar',
|
|
142
|
+
'voy a echar un vistazo',
|
|
143
|
+
'déjame echar un vistazo',
|
|
144
|
+
'ahora lo reviso',
|
|
145
|
+
'ahora reviso',
|
|
146
|
+
'ahora lo verifico',
|
|
147
|
+
'ahora mismo lo reviso',
|
|
109
148
|
'lo reviso enseguida',
|
|
110
149
|
'lo verifico enseguida',
|
|
111
150
|
'enseguida lo reviso',
|
|
@@ -123,10 +162,15 @@ const FR_PHRASES: readonly string[] = [
|
|
|
123
162
|
'je vais poursuivre',
|
|
124
163
|
'je vais voir',
|
|
125
164
|
'je vais contrôler',
|
|
165
|
+
'je vais creuser',
|
|
166
|
+
'je vais jeter un œil',
|
|
126
167
|
'laisse-moi vérifier',
|
|
127
168
|
'laisse-moi regarder',
|
|
169
|
+
'laisse-moi jeter un œil',
|
|
128
170
|
'je vérifie tout de suite',
|
|
129
171
|
'je regarde tout de suite',
|
|
172
|
+
'je regarde ça tout de suite',
|
|
173
|
+
'je regarde ça',
|
|
130
174
|
'un instant',
|
|
131
175
|
'donne-moi un instant',
|
|
132
176
|
'donne-moi une seconde',
|
|
@@ -137,12 +181,16 @@ const IT_PHRASES: readonly string[] = [
|
|
|
137
181
|
'vado a controllare',
|
|
138
182
|
'vado a verificare',
|
|
139
183
|
'vado a guardare',
|
|
184
|
+
"vado a dare un'occhiata",
|
|
140
185
|
'fammi controllare',
|
|
141
186
|
'fammi verificare',
|
|
142
187
|
'fammi guardare',
|
|
188
|
+
"fammi dare un'occhiata",
|
|
189
|
+
"do un'occhiata",
|
|
143
190
|
'controllo subito',
|
|
144
191
|
'verifico subito',
|
|
145
192
|
'continuo subito',
|
|
193
|
+
'guardo subito',
|
|
146
194
|
'un momento',
|
|
147
195
|
'dammi un momento',
|
|
148
196
|
'dammi un secondo',
|
|
@@ -156,9 +204,11 @@ const PT_PHRASES: readonly string[] = [
|
|
|
156
204
|
'vou olhar',
|
|
157
205
|
'vou continuar',
|
|
158
206
|
'vou prosseguir',
|
|
207
|
+
'vou dar uma olhada',
|
|
159
208
|
'deixa eu verificar',
|
|
160
209
|
'deixa eu conferir',
|
|
161
210
|
'deixa eu olhar',
|
|
211
|
+
'deixa eu dar uma olhada',
|
|
162
212
|
'verifico já',
|
|
163
213
|
'já verifico',
|
|
164
214
|
'um momento',
|
|
@@ -175,8 +225,13 @@ const DE_PHRASES: readonly string[] = [
|
|
|
175
225
|
'ich werde fortfahren',
|
|
176
226
|
'lass mich prüfen',
|
|
177
227
|
'lass mich nachsehen',
|
|
228
|
+
'lass mich schauen',
|
|
178
229
|
'ich schaue gleich',
|
|
230
|
+
'ich schaue mir das an',
|
|
231
|
+
'ich schaue mir das mal an',
|
|
179
232
|
'ich prüfe gleich',
|
|
233
|
+
'ich prüfe das gleich',
|
|
234
|
+
'ich sehe gleich nach',
|
|
180
235
|
'gleich prüfen',
|
|
181
236
|
'gleich überprüfen',
|
|
182
237
|
'gleich nachsehen',
|
|
@@ -194,6 +249,8 @@ const RU_PHRASES: readonly string[] = [
|
|
|
194
249
|
'я продолжу',
|
|
195
250
|
'продолжу проверку',
|
|
196
251
|
'сейчас посмотрю',
|
|
252
|
+
'дай мне проверить',
|
|
253
|
+
'дайте мне проверить',
|
|
197
254
|
'дайте мне минуту',
|
|
198
255
|
'одну секунду',
|
|
199
256
|
'минутку',
|
|
@@ -214,6 +271,10 @@ const ZH_PHRASES: readonly string[] = [
|
|
|
214
271
|
'我马上确认',
|
|
215
272
|
'我马上检查',
|
|
216
273
|
'我马上看',
|
|
274
|
+
'让我看看',
|
|
275
|
+
'让我查一下',
|
|
276
|
+
'让我确认一下',
|
|
277
|
+
'让我检查一下',
|
|
217
278
|
'稍等一下',
|
|
218
279
|
'我看一下',
|
|
219
280
|
]
|
|
@@ -265,6 +326,9 @@ const TR_PHRASES: readonly string[] = [
|
|
|
265
326
|
'kontrol edeceğim',
|
|
266
327
|
'kontrol ediyorum',
|
|
267
328
|
'bakacağım',
|
|
329
|
+
'bir bakayım',
|
|
330
|
+
'bir kontrol edeyim',
|
|
331
|
+
'kontrol edeyim',
|
|
268
332
|
'inceleyeceğim',
|
|
269
333
|
'devam edeceğim',
|
|
270
334
|
'hemen kontrol ediyorum',
|
package/src/channels/router.ts
CHANGED
|
@@ -279,6 +279,27 @@ export const WILLINGNESS_NUDGE = [
|
|
|
279
279
|
'',
|
|
280
280
|
'---',
|
|
281
281
|
].join('\n')
|
|
282
|
+
// Injected when a `channel_send` ack tripped continuation-willingness, the model
|
|
283
|
+
// did fresh work after it, then ended on an EMPTY `stop` leaf — the answer was
|
|
284
|
+
// computed but never sent (the Kimi/Fireworks empty-completion flake). Distinct
|
|
285
|
+
// from WILLINGNESS_NUDGE: that path is a `channel_reply` that ended the turn and
|
|
286
|
+
// needs `continue: true`; this path is a `channel_send` (which never ends the
|
|
287
|
+
// turn) whose follow-up degenerated, so the model just needs to emit the reply it
|
|
288
|
+
// already worked out. Shares MAX_WILLINGNESS_NUDGES so a turn can't double-nudge.
|
|
289
|
+
export const SEND_WILLINGNESS_NUDGE = [
|
|
290
|
+
'---',
|
|
291
|
+
'**[SYSTEM MESSAGE — not from a human]**',
|
|
292
|
+
'',
|
|
293
|
+
'You said you would keep working this turn and did the work, but the turn ended',
|
|
294
|
+
'without sending the result — nothing reached the channel after your last',
|
|
295
|
+
'message. This is an automated signal from the channel router, not a message',
|
|
296
|
+
'from anyone in the chat. **Do not acknowledge or reply to this notice itself.**',
|
|
297
|
+
'',
|
|
298
|
+
'Send the answer you just worked out now via your channel send tool. If you',
|
|
299
|
+
'genuinely have nothing to report, reply with `NO_REPLY`.',
|
|
300
|
+
'',
|
|
301
|
+
'---',
|
|
302
|
+
].join('\n')
|
|
282
303
|
// Rolling window for outbound send-rate telemetry. 5s matches Discord's
|
|
283
304
|
// rate-limit shape (5 msg / 5 s / channel) and comfortably covers Slack's
|
|
284
305
|
// 1 msg/s sustained. The window is observational; exceeding the burst
|
|
@@ -3358,23 +3379,6 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3358
3379
|
live.skippedTurn = null
|
|
3359
3380
|
logger.info(`[channels] ${live.keyId} skip_contested_by_send recovering reply`)
|
|
3360
3381
|
}
|
|
3361
|
-
// A send landed this turn, but the model may have posted a `continue: true`
|
|
3362
|
-
// progress reply, kept working, then ENDED with its final answer as plain
|
|
3363
|
-
// prose — never calling a channel tool again. The terminal-reply abort fires
|
|
3364
|
-
// only for a `channel_reply` WITHOUT `continue: true`, so that `stopReason:
|
|
3365
|
-
// 'stop'` text leaf is left undelivered and unguarded (the false-receipt
|
|
3366
|
-
// guard is github-only). The discriminator is leaf IDENTITY: only when the
|
|
3367
|
-
// turn-end `stop` leaf is a DIFFERENT entry than the one in place at the last
|
|
3368
|
-
// send did the model produce fresh post-reply prose. A leaf unchanged since
|
|
3369
|
-
// the send is narration the model emitted with/before the reply that already
|
|
3370
|
-
// landed — suppress it, as before.
|
|
3371
|
-
if (live.successfulChannelSends > successfulSendsBeforePrompt) {
|
|
3372
|
-
maybeNudgeContinuationWillingness(live)
|
|
3373
|
-
const trailing = recoverableAssistantText(live.session)
|
|
3374
|
-
if (trailing === null || trailing.source !== 'leaf') return
|
|
3375
|
-
if (live.session.sessionManager.getLeafEntry()?.id === live.lastSendLeafId) return
|
|
3376
|
-
}
|
|
3377
|
-
|
|
3378
3382
|
const postEmptyTurnFallback = async (cause: string): Promise<void> => {
|
|
3379
3383
|
logger.warn(`[channels] ${live.keyId} empty_turn_fallback cause=${cause}`)
|
|
3380
3384
|
const result = await send(
|
|
@@ -3392,6 +3396,86 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3392
3396
|
}
|
|
3393
3397
|
}
|
|
3394
3398
|
|
|
3399
|
+
// A send landed this turn, but the model may have posted a `continue: true`
|
|
3400
|
+
// progress reply, kept working, then ENDED with its final answer as plain
|
|
3401
|
+
// prose — never calling a channel tool again. The terminal-reply abort fires
|
|
3402
|
+
// only for a `channel_reply` WITHOUT `continue: true`, so that `stopReason:
|
|
3403
|
+
// 'stop'` text leaf is left undelivered and unguarded (the false-receipt
|
|
3404
|
+
// guard is github-only). The discriminator is leaf IDENTITY: only when the
|
|
3405
|
+
// turn-end `stop` leaf is a DIFFERENT entry than the one in place at the last
|
|
3406
|
+
// send did the model produce fresh post-reply prose. A leaf unchanged since
|
|
3407
|
+
// the send is narration the model emitted with/before the reply that already
|
|
3408
|
+
// landed — suppress it, as before.
|
|
3409
|
+
if (live.successfulChannelSends > successfulSendsBeforePrompt) {
|
|
3410
|
+
maybeNudgeContinuationWillingness(live)
|
|
3411
|
+
|
|
3412
|
+
// A `channel_send` ack that promised to keep working, fresh post-ack work,
|
|
3413
|
+
// then an EMPTY `stop` leaf: the model computed the answer in its reasoning
|
|
3414
|
+
// / tool results but never sent it (the Kimi/Fireworks empty-completion
|
|
3415
|
+
// flake). `maybeNudgeContinuationWillingness` above can't catch this — it
|
|
3416
|
+
// reads `lastTerminalReplyAbort`, which only a `channel_reply` sets;
|
|
3417
|
+
// `channel_send` keeps the turn alive and stamps nothing. And the
|
|
3418
|
+
// stranded-toolUse retry below requires `source !== 'leaf'`, but an empty
|
|
3419
|
+
// `stop` leaf recovers as `source: 'leaf'`, so this shape would otherwise
|
|
3420
|
+
// fall straight through to the `endsWithNoReplySignal('')` → `no_reply`
|
|
3421
|
+
// classification. Discriminator (all on existing state, zero false positives
|
|
3422
|
+
// measured across the session corpus): a send landed AND the just-sent text
|
|
3423
|
+
// trips the precision-tuned willingness detector AND the turn-end leaf is a
|
|
3424
|
+
// FRESH empty `stop` (different entry than the ack's leaf — so the model did
|
|
3425
|
+
// post-ack work, not an ack-then-await-user stop). Bounded by
|
|
3426
|
+
// MAX_WILLINGNESS_NUDGES (shared with the reply path); on exhaustion post the
|
|
3427
|
+
// fallback rather than going silent, mirroring the stranded-toolUse path.
|
|
3428
|
+
// Gated on an empty `promptQueue` (like maybeNudgeContinuationWillingness): a
|
|
3429
|
+
// real inbound that coalesced into the just-finished prompt will be answered
|
|
3430
|
+
// by the next drain pass, and drain() splices pending reminders into that
|
|
3431
|
+
// batch — so injecting a stale recovery nudge would prepend it to a live user
|
|
3432
|
+
// message. Skip the nudge AND the fallback in that case and let the trailing
|
|
3433
|
+
// recovery below run; the queued inbound supersedes this turn's silence.
|
|
3434
|
+
if (live.promptQueue.length === 0 && live.currentTurnAuthorId !== null && isEmptyStopAfterWillingnessAck(live)) {
|
|
3435
|
+
if (live.willingnessNudges < MAX_WILLINGNESS_NUDGES) {
|
|
3436
|
+
live.willingnessNudges++
|
|
3437
|
+
logger.warn(
|
|
3438
|
+
`[channels] ${live.keyId} send_willingness_nudge attempt=${live.willingnessNudges}/${MAX_WILLINGNESS_NUDGES} ` +
|
|
3439
|
+
`cause=empty_stop_after_send_ack`,
|
|
3440
|
+
)
|
|
3441
|
+
live.pendingSystemReminders.push(SEND_WILLINGNESS_NUDGE)
|
|
3442
|
+
} else {
|
|
3443
|
+
await postEmptyTurnFallback('empty_stop_after_send_ack_nudges_exhausted')
|
|
3444
|
+
}
|
|
3445
|
+
return
|
|
3446
|
+
}
|
|
3447
|
+
|
|
3448
|
+
const trailing = recoverableAssistantText(live.session)
|
|
3449
|
+
if (trailing === null || trailing.source !== 'leaf') {
|
|
3450
|
+
// A `continue: true` status reply landed, then the turn stranded on an
|
|
3451
|
+
// unanswered `toolUse` (the post-tool follow-up never produced an
|
|
3452
|
+
// assistant message — aborted loop / cancelled stream). The promised
|
|
3453
|
+
// work never finished, so the user is left with a bare "checking now…"
|
|
3454
|
+
// and nothing after it. Re-prompt the same logical turn so the model
|
|
3455
|
+
// completes its investigation and actually replies, instead of ending
|
|
3456
|
+
// in silence. On retry-exhaustion post the fallback rather than
|
|
3457
|
+
// returning silently — a retry turn that re-sends a status and re-strands
|
|
3458
|
+
// on the same no-prose shape must not deadair the user. Any postable
|
|
3459
|
+
// pre-tool/mid-turn prose is suppressed here as before (it was narration
|
|
3460
|
+
// that accompanied the already-landed reply); only the no-prose strand
|
|
3461
|
+
// gets a retry-or-fallback.
|
|
3462
|
+
if (leafIsStrandedToolUse(live.session) && live.currentTurnAuthorId !== null) {
|
|
3463
|
+
if (live.emptyTurnRetries < MAX_EMPTY_TURN_RETRIES) {
|
|
3464
|
+
live.emptyTurnRetries++
|
|
3465
|
+
logger.warn(
|
|
3466
|
+
`[channels] ${live.keyId} empty_turn_retry attempt=${live.emptyTurnRetries}/${MAX_EMPTY_TURN_RETRIES} ` +
|
|
3467
|
+
`cause=stranded_toolUse_after_send`,
|
|
3468
|
+
)
|
|
3469
|
+
live.pendingSystemReminders.push(EMPTY_TURN_RETRY_NUDGE)
|
|
3470
|
+
} else {
|
|
3471
|
+
await postEmptyTurnFallback('stranded_toolUse_retries_exhausted')
|
|
3472
|
+
}
|
|
3473
|
+
}
|
|
3474
|
+
return
|
|
3475
|
+
}
|
|
3476
|
+
if (live.session.sessionManager.getLeafEntry()?.id === live.lastSendLeafId) return
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3395
3479
|
let candidate = recoverableAssistantText(live.session)
|
|
3396
3480
|
// A `length` leaf is recovered ONLY when stripping leaked `<think>…</think>`
|
|
3397
3481
|
// spans actually removed something AND leaves a postable reply. The removal
|
|
@@ -4961,6 +5045,58 @@ function assistantLeafStopReason(session: AgentSession): 'length' | 'error' | 'a
|
|
|
4961
5045
|
return undefined
|
|
4962
5046
|
}
|
|
4963
5047
|
|
|
5048
|
+
// True when the branch ends on an UNANSWERED `toolUse` that left NO postable
|
|
5049
|
+
// prose — the model called a tool and the upstream pi-agent-core post-tool
|
|
5050
|
+
// follow-up never produced an assistant message (the loop was aborted, or the
|
|
5051
|
+
// follow-up stream cancelled). Two leaf shapes carry this signature: the leaf
|
|
5052
|
+
// IS a `toolUse` assistant, or the leaf is a `toolResult` whose nearest
|
|
5053
|
+
// assistant ancestor (reached before any user message) is `toolUse`. The
|
|
5054
|
+
// no-prose requirement is the discriminator from a model that narrated a reply
|
|
5055
|
+
// alongside its tool call and DID land a real send this turn (that trailing
|
|
5056
|
+
// `toolUse` is delivered narration, not a stranded promise — leave it alone).
|
|
5057
|
+
// Keys on the model having INTENDED to keep working with nothing yet said; used
|
|
5058
|
+
// to re-prompt a turn that strands mid-work after a `continue: true` status
|
|
5059
|
+
// reply instead of ending in silence.
|
|
5060
|
+
function leafIsStrandedToolUse(session: AgentSession): boolean {
|
|
5061
|
+
const leaf = session.sessionManager.getLeafEntry()
|
|
5062
|
+
if (!leaf || leaf.type !== 'message') return false
|
|
5063
|
+
if (leaf.message.role === 'assistant') {
|
|
5064
|
+
return leaf.message.stopReason === 'toolUse' && visibleAssistantText(leaf.message).trim() === ''
|
|
5065
|
+
}
|
|
5066
|
+
if (leaf.message.role !== 'toolResult') return false
|
|
5067
|
+
let cursor: { parentId: string | null } | undefined = leaf
|
|
5068
|
+
for (let depth = 0; depth < 32 && cursor?.parentId; depth++) {
|
|
5069
|
+
const parent = session.sessionManager.getEntry(cursor.parentId)
|
|
5070
|
+
if (!parent) return false
|
|
5071
|
+
if (parent.type === 'message') {
|
|
5072
|
+
if (parent.message.role === 'assistant') {
|
|
5073
|
+
return parent.message.stopReason === 'toolUse' && visibleAssistantText(parent.message).trim() === ''
|
|
5074
|
+
}
|
|
5075
|
+
if (parent.message.role === 'user') return false
|
|
5076
|
+
}
|
|
5077
|
+
cursor = parent
|
|
5078
|
+
}
|
|
5079
|
+
return false
|
|
5080
|
+
}
|
|
5081
|
+
|
|
5082
|
+
// True when the turn-end leaf is a FRESH empty `stop` (no text, no tool call,
|
|
5083
|
+
// distinct from the leaf in place at the last successful send) AND the most
|
|
5084
|
+
// recent send to this target was a continuation-willingness ack. This is the
|
|
5085
|
+
// `channel_send` analogue of the `channel_reply` willingness path: the model
|
|
5086
|
+
// acked "I'll check…", did post-ack work, then the follow-up came back as a
|
|
5087
|
+
// clean empty completion that would otherwise be read as a deliberate `NO_REPLY`.
|
|
5088
|
+
// The fresh-leaf check (`!== lastSendLeafId`) is what separates this degeneration
|
|
5089
|
+
// from a legitimate ack-then-stop where the model meant to wait for the user.
|
|
5090
|
+
function isEmptyStopAfterWillingnessAck(live: LiveSession): boolean {
|
|
5091
|
+
const leaf = live.session.sessionManager.getLeafEntry()
|
|
5092
|
+
if (!leaf || leaf.type !== 'message' || leaf.message.role !== 'assistant') return false
|
|
5093
|
+
if (leaf.message.stopReason !== 'stop') return false
|
|
5094
|
+
if (hasToolCall(leaf.message) || visibleAssistantText(leaf.message).trim() !== '') return false
|
|
5095
|
+
if (leaf.id === live.lastSendLeafId) return false
|
|
5096
|
+
const ackText = live.lastSentText.get(consecutiveSendKey(live.key.chat, live.key.thread))
|
|
5097
|
+
return ackText !== undefined && detectContinuationWillingness(ackText)
|
|
5098
|
+
}
|
|
5099
|
+
|
|
4964
5100
|
function visibleAssistantText(message: AssistantMessage): string {
|
|
4965
5101
|
return message.content
|
|
4966
5102
|
.filter((block) => block.type === 'text')
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Option } from '@clack/prompts'
|
|
2
|
+
|
|
3
|
+
type FuzzyFilter<Value> = (search: string, option: Option<Value>) => boolean
|
|
4
|
+
|
|
5
|
+
function optionHaystack<Value>(option: Option<Value>): string {
|
|
6
|
+
const label = option.label ?? String(option.value)
|
|
7
|
+
const hint = option.hint ?? ''
|
|
8
|
+
return `${label} ${String(option.value)} ${hint}`.toLowerCase()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isSubsequence(query: string, haystack: string): boolean {
|
|
12
|
+
let i = 0
|
|
13
|
+
for (let j = 0; j < haystack.length && i < query.length; j++) {
|
|
14
|
+
if (haystack[j] === query[i]) i++
|
|
15
|
+
}
|
|
16
|
+
return i === query.length
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Splitting the query on whitespace lets "gpt 5.5" match "GPT-5.5 Turbo": each
|
|
20
|
+
// token is matched independently as a subsequence, so the "-" inside "GPT-5.5"
|
|
21
|
+
// no longer breaks the search the way a plain substring "gpt 5.5" would. Tokens
|
|
22
|
+
// are order-independent so "turbo gpt" finds "GPT Turbo" too.
|
|
23
|
+
export function fuzzyMatch<Value>(search: string, option: Option<Value>): boolean {
|
|
24
|
+
const tokens = search.toLowerCase().split(/\s+/).filter(Boolean)
|
|
25
|
+
if (tokens.length === 0) return true
|
|
26
|
+
const haystack = optionHaystack(option)
|
|
27
|
+
return tokens.every((token) => isSubsequence(token, haystack))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const fuzzyFilter: FuzzyFilter<unknown> = fuzzyMatch
|
|
31
|
+
|
|
32
|
+
export type { FuzzyFilter }
|
package/src/cli/init.ts
CHANGED
|
@@ -66,6 +66,7 @@ import {
|
|
|
66
66
|
type KeyValidationResult,
|
|
67
67
|
} from '@/init/validate-api-key'
|
|
68
68
|
|
|
69
|
+
import { fuzzyMatch } from './fuzzy-filter'
|
|
69
70
|
import { buildOAuthCallbacks } from './oauth-callbacks'
|
|
70
71
|
import { CANCEL_SYMBOL, promptPrivateKeyPem } from './prompt-pem'
|
|
71
72
|
import {
|
|
@@ -1098,6 +1099,7 @@ async function pickVendor(
|
|
|
1098
1099
|
const choice = await autocomplete({
|
|
1099
1100
|
message: 'Pick an LLM provider',
|
|
1100
1101
|
placeholder: 'Type to search…',
|
|
1102
|
+
filter: fuzzyMatch,
|
|
1101
1103
|
options: vendors.map((id) => ({
|
|
1102
1104
|
value: id,
|
|
1103
1105
|
label: KNOWN_PROVIDER_VENDORS[id].name,
|
|
@@ -1120,6 +1122,7 @@ async function pickProviderVariant(
|
|
|
1120
1122
|
const choice = await autocomplete<KnownProviderId>({
|
|
1121
1123
|
message: `Pick a ${KNOWN_PROVIDER_VENDORS[vendorId].name} option`,
|
|
1122
1124
|
placeholder: 'Type to search…',
|
|
1125
|
+
filter: fuzzyMatch,
|
|
1123
1126
|
options: variants.map((id) => {
|
|
1124
1127
|
const hint = variantHint(vendorId, id)
|
|
1125
1128
|
return hint !== undefined
|
|
@@ -1145,6 +1148,7 @@ async function pickModelForProvider(
|
|
|
1145
1148
|
const choice = await autocomplete<string>({
|
|
1146
1149
|
message: `Pick a ${KNOWN_PROVIDERS[providerId].name} model`,
|
|
1147
1150
|
placeholder: 'Type to search…',
|
|
1151
|
+
filter: fuzzyMatch,
|
|
1148
1152
|
options: candidates.map((o) => ({
|
|
1149
1153
|
value: o.ref,
|
|
1150
1154
|
label: formatModelLabel(o),
|
|
@@ -1191,6 +1195,7 @@ async function pickVisionVendor(
|
|
|
1191
1195
|
const choice = await autocomplete<KnownProviderVendorId | 'skip'>({
|
|
1192
1196
|
message: 'Your model is text-only. Pick a provider for the `vision` profile (used for image input)',
|
|
1193
1197
|
placeholder: 'Type to search…',
|
|
1198
|
+
filter: fuzzyMatch,
|
|
1194
1199
|
options: [
|
|
1195
1200
|
...vendors.map((id) => ({
|
|
1196
1201
|
value: id as KnownProviderVendorId | 'skip',
|
|
@@ -1223,6 +1228,7 @@ async function pickVisionModel(
|
|
|
1223
1228
|
const choice = await autocomplete<string>({
|
|
1224
1229
|
message: `Pick a vision-capable ${KNOWN_PROVIDERS[providerId].name} model`,
|
|
1225
1230
|
placeholder: 'Type to search…',
|
|
1231
|
+
filter: fuzzyMatch,
|
|
1226
1232
|
options: candidates.map((o) => ({
|
|
1227
1233
|
value: o.ref,
|
|
1228
1234
|
label: formatModelLabel(o),
|
package/src/cli/model.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { findAgentDir, isInitialized } from '@/init'
|
|
20
20
|
import { customModelMetaFromOption, fetchModelOptions, type ModelOption } from '@/init/models-dev'
|
|
21
21
|
|
|
22
|
+
import { fuzzyMatch } from './fuzzy-filter'
|
|
22
23
|
import { runProviderAddFlow } from './provider'
|
|
23
24
|
import { c, done, errorLine } from './ui'
|
|
24
25
|
|
|
@@ -251,6 +252,7 @@ async function pickModelRef(cwd: string): Promise<PickedModelRef> {
|
|
|
251
252
|
const choice = await autocomplete<string>({
|
|
252
253
|
message: 'Pick a model',
|
|
253
254
|
placeholder: 'Type to search…',
|
|
255
|
+
filter: fuzzyMatch,
|
|
254
256
|
options: [
|
|
255
257
|
...modelOptions.map((option) => ({
|
|
256
258
|
value: option.ref,
|
package/src/cli/provider.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { findAgentDir, isInitialized } from '@/init'
|
|
24
24
|
import { makeOAuthLoginRunner } from '@/init/oauth-login'
|
|
25
25
|
|
|
26
|
+
import { fuzzyMatch } from './fuzzy-filter'
|
|
26
27
|
import { buildOAuthCallbacks } from './oauth-callbacks'
|
|
27
28
|
import { c, done, errorLine } from './ui'
|
|
28
29
|
|
|
@@ -285,6 +286,7 @@ async function pickVendorToAdd(): Promise<KnownProviderVendorId> {
|
|
|
285
286
|
const choice = await autocomplete<KnownProviderVendorId>({
|
|
286
287
|
message: 'Pick a provider to add',
|
|
287
288
|
placeholder: 'Type to search…',
|
|
289
|
+
filter: fuzzyMatch,
|
|
288
290
|
options: vendorIds.map((id) => ({
|
|
289
291
|
value: id,
|
|
290
292
|
label: KNOWN_PROVIDER_VENDORS[id].name,
|
|
@@ -305,6 +307,7 @@ async function pickVariantToAdd(vendorId: KnownProviderVendorId): Promise<KnownP
|
|
|
305
307
|
const choice = await autocomplete<KnownProviderId>({
|
|
306
308
|
message: `Pick a ${KNOWN_PROVIDER_VENDORS[vendorId].name} option`,
|
|
307
309
|
placeholder: 'Type to search…',
|
|
310
|
+
filter: fuzzyMatch,
|
|
308
311
|
options: variants.map((id) => {
|
|
309
312
|
const hint = variantHint(vendorId, id)
|
|
310
313
|
return hint !== undefined
|
package/src/config/providers.ts
CHANGED
|
@@ -991,9 +991,12 @@ function knownProviderForModelRef(ref: string): KnownProviderId | null {
|
|
|
991
991
|
//
|
|
992
992
|
// Anthropic, GLM, and Kimi don't share the padding behavior, so they keep the
|
|
993
993
|
// SDK default.
|
|
994
|
+
export function isOpenAiFamilyRef(ref: KnownModelRef | ModelRef | string): boolean {
|
|
995
|
+
return vendorForProviderId(providerForModelRef(ref)) === 'openai'
|
|
996
|
+
}
|
|
997
|
+
|
|
994
998
|
export function defaultThinkingLevelForRef(ref: KnownModelRef | ModelRef | string): 'low' | undefined {
|
|
995
|
-
|
|
996
|
-
if (providerId === 'openai' || providerId === 'openai-codex') return 'low'
|
|
999
|
+
if (isOpenAiFamilyRef(ref)) return 'low'
|
|
997
1000
|
return undefined
|
|
998
1001
|
}
|
|
999
1002
|
|