@soffits/dsh-fallback-models 0.0.0-stage → 0.2.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/src/refusal.js ADDED
@@ -0,0 +1,163 @@
1
+ // Refusal detection. A provider's content filter or a model that declines
2
+ // answers with an ordinary successful response, so DSH reports no failure and
3
+ // no recovery runs. This module recognises such a reply at the end of its
4
+ // stream and replaces the stream's `stop` finish with an `error` finish of
5
+ // code REFUSAL. The agent loop then records the reply as a failed attempt
6
+ // (kept in the log, never shown as the answer and never sent to a model) and
7
+ // asks `agent/request-error`, where the ordinary fallback chain takes over.
8
+ //
9
+ // Detection is deliberately narrow, because a false positive throws away a
10
+ // good answer and pays for a second model. All of these must hold:
11
+ // - the reply has text and no tool calls, and stopped normally;
12
+ // - no tool call succeeded since the user's last message (a turn that already
13
+ // did work is not refused, whatever its last words are);
14
+ // - the reply is short (`maxLength` characters);
15
+ // - a pattern matches within its opening (`OPENING` characters), not right
16
+ // after a quotation mark, so a reply that quotes or discusses a refusal
17
+ // later on is left alone.
18
+
19
+ import { REFUSAL_CODE } from './policy.js'
20
+
21
+ /**
22
+ * Built-in refusal signatures: regular-expression sources, matched
23
+ * case-insensitively. They cover provider filter notices and the common
24
+ * opening sentences of a declined request, in English and Chinese.
25
+ */
26
+ export const DEFAULT_REFUSAL_PATTERNS = Object.freeze([
27
+ // Provider filter notices (Gemini: "This request was blocked by Google's filters").
28
+ String.raw`\b(?:this|the|your) (?:request|prompt|response|content|message) (?:was|has been|is) blocked\b`,
29
+ String.raw`\bblocked by (?:[\w'’ ]{0,40})?(?:safety|content) (?:filters?|policy|system)\b`,
30
+ // "I can't help with that", "I cannot assist with this request", "I won't help with ...".
31
+ String.raw`\bI (?:can['’]?t|cannot|can not|won['’]?t|will not|am unable to|am not able to) (?:help|assist)(?: you)? with (?:that|this|it|your request|the request)\b`,
32
+ // "I'm not able to assist", "I'm unable to help with that", "Sorry, I'm not able to help".
33
+ String.raw`\bI['’]?m (?:not able|unable|not going) to (?:help|assist)\b`,
34
+ // "I can't provide/fulfil/comply with that".
35
+ String.raw`\bI (?:can['’]?t|cannot|won['’]?t|will not) (?:provide|fulfill|fulfil|comply with) (?:that|this)(?: request)?\b`,
36
+ // Chinese: 我不能协助 / 我无法帮助 / 抱歉,不能提供这类内容 ...
37
+ String.raw`我(?:不能|无法|没法|不可以|不会|不便)(?:协助|帮助|帮你|帮您|帮忙|提供|满足|完成|回答|处理)`,
38
+ String.raw`(?:抱歉|对不起)[,,。!!\s]*(?:不能|无法)(?:协助|帮助|提供|满足|回答)(?:这|此|你|您)`,
39
+ ])
40
+
41
+ /** Default upper bound on a refusal's length, in characters. */
42
+ export const DEFAULT_REFUSAL_MAX_LENGTH = 500
43
+
44
+ /** A signature must start within this many characters of the reply. */
45
+ export const OPENING = 160
46
+
47
+ const QUOTES = new Set(['"', '“', '”', '\'', '‘', '’', '「', '『', '`', '«'])
48
+
49
+ /**
50
+ * Compile configured pattern sources, skipping (and reporting) invalid ones.
51
+ * @param sources - regular-expression sources.
52
+ * @param warn - called with (source, message) for each invalid pattern.
53
+ * @returns compiled case-insensitive Unicode expressions.
54
+ */
55
+ export function compilePatterns(sources, warn = () => {}) {
56
+ const out = []
57
+ for (const source of sources ?? []) {
58
+ if (typeof source !== 'string' || source.trim() === '') continue
59
+ try {
60
+ out.push(new RegExp(source, 'iu'))
61
+ } catch (error) {
62
+ warn(source, error instanceof Error ? error.message : String(error))
63
+ }
64
+ }
65
+ return out
66
+ }
67
+
68
+ /**
69
+ * Whether a finished reply's text reads as a refusal or a filter block.
70
+ * @param text - the reply's visible text.
71
+ * @param patterns - compiled signatures.
72
+ * @param maxLength - longest text still treated as a refusal.
73
+ * @returns the matched snippet, or undefined.
74
+ */
75
+ export function refusalMatch(text, patterns, maxLength = DEFAULT_REFUSAL_MAX_LENGTH) {
76
+ const reply = String(text ?? '').trim()
77
+ if (reply === '' || reply.length > maxLength) return undefined
78
+ const opening = reply.slice(0, OPENING + 80)
79
+ for (const pattern of patterns) {
80
+ const match = pattern.exec(opening)
81
+ if (!match || match.index > OPENING) continue
82
+ if (QUOTES.has(opening[match.index - 1])) continue
83
+ return match[0]
84
+ }
85
+ return undefined
86
+ }
87
+
88
+ /**
89
+ * Whether a tool call succeeded since the last message the user wrote, read
90
+ * from the request's history (a loop-built request is the session's
91
+ * model-visible log).
92
+ * @param messages - the request messages.
93
+ */
94
+ export function toolSucceededThisTurn(messages) {
95
+ for (let i = (messages?.length ?? 0) - 1; i >= 0; i--) {
96
+ const message = messages[i]
97
+ if (message.role === 'tool') {
98
+ if (message.isError !== true) return true
99
+ continue
100
+ }
101
+ if (message.role === 'user' && message.source?.kind === 'user') return false
102
+ }
103
+ return false
104
+ }
105
+
106
+ /** The reply's visible text and whether it called tools, from its stream chunks. */
107
+ class ReplyReader {
108
+ constructor() {
109
+ this.deltas = new Map()
110
+ this.blocks = new Map()
111
+ this.toolCalls = false
112
+ }
113
+
114
+ push(chunk) {
115
+ if (chunk.type === 'text-delta') this.deltas.set(chunk.index, (this.deltas.get(chunk.index) ?? '') + chunk.text)
116
+ else if (chunk.type === 'tool-call-delta') this.toolCalls = true
117
+ else if (chunk.type === 'block-start' && chunk.blockType === 'tool-call') this.toolCalls = true
118
+ else if (chunk.type === 'block-end') {
119
+ if (chunk.block?.type === 'text') this.blocks.set(chunk.index, chunk.block.text)
120
+ else if (chunk.block?.type === 'tool-call') this.toolCalls = true
121
+ }
122
+ }
123
+
124
+ text() {
125
+ const indexes = [...new Set([...this.deltas.keys(), ...this.blocks.keys()])].sort((a, b) => a - b)
126
+ return indexes.map(index => this.blocks.get(index) ?? this.deltas.get(index)).join('\n')
127
+ }
128
+ }
129
+
130
+ /** The failure reported for a detected refusal. */
131
+ export function refusalFailure(text) {
132
+ const reply = String(text).replace(/\s+/g, ' ').trim()
133
+ const short = reply.length > 160 ? `${reply.slice(0, 159)}…` : reply
134
+ return Object.freeze({ code: REFUSAL_CODE, message: `the model declined to answer: "${short}"` })
135
+ }
136
+
137
+ /**
138
+ * Pass a model stream through unchanged, except that a normal finish of a
139
+ * reply that `check` accepts as a refusal becomes an error finish with code
140
+ * REFUSAL. Chunks are not held back, so answers stream as before.
141
+ * @param stream - the inner stream.
142
+ * @param check - async (text) => boolean, asked only for a finished, short,
143
+ * tool-free reply whose opening matched a pattern.
144
+ * @param options - `{ patterns, maxLength }`.
145
+ */
146
+ export async function * watchRefusal(stream, check, { patterns, maxLength }) {
147
+ const reader = new ReplyReader()
148
+ for await (const chunk of stream) {
149
+ if (chunk.type !== 'finish') {
150
+ reader.push(chunk)
151
+ yield chunk
152
+ continue
153
+ }
154
+ if (chunk.reason?.kind === 'stop' && !reader.toolCalls) {
155
+ const text = reader.text()
156
+ if (refusalMatch(text, patterns, maxLength) !== undefined && await check(text)) {
157
+ yield { type: 'finish', reason: { kind: 'error', failure: refusalFailure(text) } }
158
+ continue
159
+ }
160
+ }
161
+ yield chunk
162
+ }
163
+ }