@soffits/dsh-fallback-models 0.2.1 → 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 +8 -4
- package/package.json +1 -1
- package/src/config.js +2 -2
- package/src/fallback.js +4 -38
- package/src/notify.js +139 -0
package/README.md
CHANGED
|
@@ -44,8 +44,7 @@ The settings page lists models from DSH's catalog (the same set the model picker
|
|
|
44
44
|
# codes: [...] # optional, see below
|
|
45
45
|
# refusalPatterns: [...] # optional, see "Refused answers"
|
|
46
46
|
notify: # optional, see "Notifications"
|
|
47
|
-
via: telegramTopics
|
|
48
|
-
to: "196554"
|
|
47
|
+
via: telegramTopics # to the conversation's own topic; add `to: "<thread id>"` for one fixed topic
|
|
49
48
|
```
|
|
50
49
|
|
|
51
50
|
| Field | Default | Meaning |
|
|
@@ -56,7 +55,7 @@ The settings page lists models from DSH's catalog (the same set the model picker
|
|
|
56
55
|
| `codes` | see below | Failure codes that cause a switch to another model. |
|
|
57
56
|
| `refusalPatterns` | built-in list | Regular expressions that mark a reply as refused. An empty list turns refusal detection off. |
|
|
58
57
|
| `refusalMaxLength` | `500` | Longest reply, in characters, that can count as a refusal. |
|
|
59
|
-
| `notify` | none | `{ via, to }`: a DSH service with a `send({ to, text, files, from })` method
|
|
58
|
+
| `notify` | none | `{ via, to }`: a DSH service with a `send({ to, chat, text, files, from })` method. Without `to`, each message goes to the conversation the switch happened in; with `to`, always to that address. See "Notifications". |
|
|
60
59
|
|
|
61
60
|
All fields apply at the next failure; DSH does not need a restart.
|
|
62
61
|
|
|
@@ -113,7 +112,12 @@ The new turn sees the whole conversation (your latest message, any tool results,
|
|
|
113
112
|
The plugin depends on no other plugin. Each switch is also:
|
|
114
113
|
|
|
115
114
|
- emitted as the Host event `fallback-models/switch`, with `{ agent, sessionId, turn, from, to, failure, refusal }`, for any plugin that wants to listen;
|
|
116
|
-
- sent through `notify`, when
|
|
115
|
+
- sent through `notify`, when `via` names a service: the plugin looks the service up at the time of the switch and calls its `send({ to, chat, text, files: [], from: { label: 'dsh-fallback-models', sessionId } })` with one short message (worded apart for a refusal). A missing service or a failed send is logged and changes nothing else.
|
|
116
|
+
|
|
117
|
+
Where the message goes depends on `to`:
|
|
118
|
+
|
|
119
|
+
- Empty (the default): to the conversation the switch happened in. The service has to say where conversations live through a `topics()` method that returns `[{ sessionId, threadId, chatId }]`, as the topic service of [dsh-telegram-bot](https://github.com/soffits/dsh-telegram-bot) (`telegramTopics`) does; the message goes to the entry bound to the session, with `to` set to its `threadId` and `chat` to its `chatId`. When a subagent's model is switched, the plugin follows the subagent's parents up to the nearest conversation that has an entry and sends there, saying it happened in a subagent. A conversation with no entry anywhere up its chain, such as one opened only in the Web UI, gets no message. Ordinary forks count as conversations of their own.
|
|
120
|
+
- Set: every message goes to that one address, such as a topic's thread id, whatever conversation it came from.
|
|
117
121
|
|
|
118
122
|
### What reaches the model
|
|
119
123
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soffits/dsh-fallback-models",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Fallback model chain for DeepSeek Harness: when a model request keeps failing after DSH's own retries, the work continues on the next model in a configured list.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
package/src/config.js
CHANGED
|
@@ -26,8 +26,8 @@ export const Config = z.object({
|
|
|
26
26
|
refusalMaxLength: z.natural().default(DEFAULT_REFUSAL_MAX_LENGTH).volatile()
|
|
27
27
|
.description('Longest reply, in characters, that can count as a refusal.'),
|
|
28
28
|
notify: z.object({
|
|
29
|
-
via: z.string().description('Name of a running DSH service with a send({ to, text, files, from }) method, such as a Telegram plugin\'s topic service. Empty: no message.'),
|
|
30
|
-
to: z.string().description('
|
|
29
|
+
via: z.string().description('Name of a running DSH service with a send({ to, chat, text, files, from }) method, such as a Telegram plugin\'s topic service. Empty: no message.'),
|
|
30
|
+
to: z.string().description('Empty (the default): send to the conversation the switch happened in, as the service\'s topics() list places it; a subagent\'s switch goes to its nearest parent conversation that has a place, and nothing is sent when none has. Set: send every message to this one address, such as a topic\'s thread id.'),
|
|
31
31
|
}).default({}).volatile()
|
|
32
32
|
.description('Optional: also send one short message per switch through another plugin\'s service. Nothing is required; the switch is always shown in the conversation and emitted as the fallback-models/switch event.'),
|
|
33
33
|
})
|
package/src/fallback.js
CHANGED
|
@@ -19,13 +19,14 @@
|
|
|
19
19
|
// Each switch is announced three ways, none of which needs another plugin:
|
|
20
20
|
// a banner in the Web UI conversation (client.js renders the interrupted
|
|
21
21
|
// turn's end), a `fallback-models/switch` event other plugins may listen to,
|
|
22
|
-
// and, when `notify` names a service, one
|
|
23
|
-
//
|
|
22
|
+
// and, when `notify` names a service, one message through it, to a fixed
|
|
23
|
+
// address or to the conversation's own place (src/notify.js).
|
|
24
24
|
import { randomUUID } from 'node:crypto'
|
|
25
25
|
import {
|
|
26
26
|
REFUSAL_CODE, boundSummary, candidates, chainFor, failureText, isRefusal, read,
|
|
27
27
|
routeKey, routeLabel, sameRoute, switches, toSelection,
|
|
28
28
|
} from './policy.js'
|
|
29
|
+
import { sendNotice } from './notify.js'
|
|
29
30
|
import {
|
|
30
31
|
DEFAULT_REFUSAL_MAX_LENGTH, DEFAULT_REFUSAL_PATTERNS, compilePatterns,
|
|
31
32
|
toolSucceededThisTurn, watchRefusal,
|
|
@@ -49,21 +50,6 @@ export function switchReason(from, to, failure) {
|
|
|
49
50
|
return `${REASON_PREFIX}: ${routeLabel(from)} ${what} (${failure.code}); continuing on ${routeLabel(to)}`
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
/**
|
|
53
|
-
* The message sent through `notify` for one switch. A refusal is worded
|
|
54
|
-
* apart: the reply was hidden, not lost to an error.
|
|
55
|
-
*/
|
|
56
|
-
export function switchText(info) {
|
|
57
|
-
const from = routeLabel(info.from)
|
|
58
|
-
const to = routeLabel(info.to)
|
|
59
|
-
if (info.refusal) {
|
|
60
|
-
return `Model fallback: ${from} refused to answer or its filter blocked the reply, in session ${info.sessionId}. `
|
|
61
|
-
+ `The refused reply was hidden, and ${to} is answering instead.`
|
|
62
|
-
}
|
|
63
|
-
return `Model fallback: ${from} failed (${failureText(info.failure)}) in session ${info.sessionId}. `
|
|
64
|
-
+ `The work continues on ${to}.`
|
|
65
|
-
}
|
|
66
|
-
|
|
67
53
|
function freeze(value) {
|
|
68
54
|
if (value && typeof value === 'object') {
|
|
69
55
|
for (const key of Object.keys(value)) freeze(value[key])
|
|
@@ -250,27 +236,7 @@ export function installFallback(ctx, config) {
|
|
|
250
236
|
} catch (error) {
|
|
251
237
|
logger.warn('a %s listener failed: %s', SWITCH_EVENT, error instanceof Error ? error.message : String(error))
|
|
252
238
|
}
|
|
253
|
-
|
|
254
|
-
if (via === '') return
|
|
255
|
-
let service
|
|
256
|
-
try {
|
|
257
|
-
service = ctx.get(via)
|
|
258
|
-
} catch {
|
|
259
|
-
service = undefined
|
|
260
|
-
}
|
|
261
|
-
if (typeof service?.send !== 'function') {
|
|
262
|
-
logger.warn('notify: no running service %s with a send method', via)
|
|
263
|
-
return
|
|
264
|
-
}
|
|
265
|
-
const request = {
|
|
266
|
-
to: typeof notify.to === 'string' ? notify.to.trim() : notify.to,
|
|
267
|
-
text: switchText(info),
|
|
268
|
-
files: [],
|
|
269
|
-
from: { label: 'dsh-fallback-models', sessionId: info.sessionId },
|
|
270
|
-
}
|
|
271
|
-
Promise.resolve()
|
|
272
|
-
.then(() => service.send(request))
|
|
273
|
-
.catch(error => logger.warn('notify through %s failed: %s', via, error instanceof Error ? error.message : String(error)))
|
|
239
|
+
void sendNotice(ctx, logger, info, notify)
|
|
274
240
|
}
|
|
275
241
|
|
|
276
242
|
/**
|
package/src/notify.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// The optional message sent through another plugin's service for each switch.
|
|
2
|
+
//
|
|
3
|
+
// `notify.via` names a running DSH service with `send({ to, chat, text, files,
|
|
4
|
+
// from })`. With `notify.to` set, every message goes to that one address. With
|
|
5
|
+
// `notify.to` empty, the message goes to the place of the conversation the
|
|
6
|
+
// switch happened in: the service's `topics()` lists the places it knows, each
|
|
7
|
+
// bound to a session, and a subagent's switch goes to the nearest ancestor
|
|
8
|
+
// session that has one. No place in the chain means no message.
|
|
9
|
+
import { failureText, routeLabel } from './policy.js'
|
|
10
|
+
|
|
11
|
+
/** Stop walking up a parent chain after this many links. */
|
|
12
|
+
export const MAX_PARENT_HOPS = 32
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Where the message is going, relative to the switching session.
|
|
16
|
+
* - `address`: a fixed `notify.to`, maybe unrelated to the session.
|
|
17
|
+
* - `own`: the place bound to the switching session itself.
|
|
18
|
+
* - `parent`: the place of an ancestor; the switch happened in a subagent.
|
|
19
|
+
*/
|
|
20
|
+
function whereText(info, relation) {
|
|
21
|
+
if (relation === 'own') return 'in this conversation'
|
|
22
|
+
if (relation === 'parent') return `in a subagent of this conversation (session ${info.sessionId})`
|
|
23
|
+
return `in session ${info.sessionId}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The message for one switch. A refusal is worded apart: the reply was
|
|
28
|
+
* hidden, not lost to an error.
|
|
29
|
+
* @param info - the switch, as emitted on `fallback-models/switch`.
|
|
30
|
+
* @param relation - `address` (default), `own` or `parent`; see whereText.
|
|
31
|
+
*/
|
|
32
|
+
export function switchText(info, relation = 'address') {
|
|
33
|
+
const from = routeLabel(info.from)
|
|
34
|
+
const to = routeLabel(info.to)
|
|
35
|
+
const where = whereText(info, relation)
|
|
36
|
+
if (info.refusal) {
|
|
37
|
+
return `Model fallback: ${from} refused to answer or its filter blocked the reply, ${where}. `
|
|
38
|
+
+ `The refused reply was hidden, and ${to} is answering instead.`
|
|
39
|
+
}
|
|
40
|
+
return `Model fallback: ${from} failed (${failureText(info.failure)}) ${where}. `
|
|
41
|
+
+ `The work continues on ${to}.`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Header of a session by id: a live agent's first, else the stored one. */
|
|
45
|
+
async function headerOf(ctx, id) {
|
|
46
|
+
try {
|
|
47
|
+
const live = ctx.get('agents')?.get(id)?.session?.header
|
|
48
|
+
if (live) return live
|
|
49
|
+
} catch {
|
|
50
|
+
// no agent registry: fall through to storage
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
return (await ctx.get('sessionPersistence')?.stat(id))?.header
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The place of a session or of its nearest subagent ancestor.
|
|
61
|
+
* Only `origin: 'subagent'` links are followed: an ordinary fork is a
|
|
62
|
+
* conversation of its own, even when its header names a source session.
|
|
63
|
+
* @param ctx - plugin context, for reading parent headers.
|
|
64
|
+
* @param topics - the service's places: `[{ sessionId, threadId, chatId? }]`.
|
|
65
|
+
* @param header - the switching session's header.
|
|
66
|
+
* @returns `{ topic, relation }`, or undefined when no session in the chain has a place.
|
|
67
|
+
*/
|
|
68
|
+
export async function conversationPlace(ctx, topics, header) {
|
|
69
|
+
const places = new Map()
|
|
70
|
+
for (const topic of Array.isArray(topics) ? topics : []) {
|
|
71
|
+
if (topic?.sessionId == null || topic.threadId == null) continue
|
|
72
|
+
const key = String(topic.sessionId)
|
|
73
|
+
if (!places.has(key)) places.set(key, topic)
|
|
74
|
+
}
|
|
75
|
+
const seen = new Set()
|
|
76
|
+
let current = header
|
|
77
|
+
for (let hops = 0; current && hops <= MAX_PARENT_HOPS; hops++) {
|
|
78
|
+
const id = String(current.id)
|
|
79
|
+
if (seen.has(id)) return undefined
|
|
80
|
+
seen.add(id)
|
|
81
|
+
const topic = places.get(id)
|
|
82
|
+
if (topic) return { topic, relation: hops === 0 ? 'own' : 'parent' }
|
|
83
|
+
if (current.origin !== 'subagent' || current.parentSession === undefined) return undefined
|
|
84
|
+
current = await headerOf(ctx, current.parentSession)
|
|
85
|
+
}
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Send one switch message through `notify`, when it names a service.
|
|
91
|
+
* Never throws: a missing service, an unknown place or a failed send is
|
|
92
|
+
* logged and changes nothing else.
|
|
93
|
+
* @param ctx - plugin context.
|
|
94
|
+
* @param logger - the plugin's logger.
|
|
95
|
+
* @param info - the switch; `info.agent.session.header` locates the conversation.
|
|
96
|
+
* @param notify - the `notify` setting: `{ via, to }`.
|
|
97
|
+
*/
|
|
98
|
+
export async function sendNotice(ctx, logger, info, notify) {
|
|
99
|
+
const via = typeof notify?.via === 'string' ? notify.via.trim() : ''
|
|
100
|
+
if (via === '') return
|
|
101
|
+
let service
|
|
102
|
+
try {
|
|
103
|
+
service = ctx.get(via)
|
|
104
|
+
} catch {
|
|
105
|
+
service = undefined
|
|
106
|
+
}
|
|
107
|
+
if (typeof service?.send !== 'function') {
|
|
108
|
+
logger.warn('notify: no running service %s with a send method', via)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
const to = typeof notify.to === 'string' ? notify.to.trim() : notify.to == null ? '' : String(notify.to)
|
|
112
|
+
const from = { label: 'dsh-fallback-models', sessionId: info.sessionId }
|
|
113
|
+
try {
|
|
114
|
+
let request
|
|
115
|
+
if (to !== '') {
|
|
116
|
+
request = { to, text: switchText(info, 'address'), files: [], from }
|
|
117
|
+
} else {
|
|
118
|
+
if (typeof service.topics !== 'function') {
|
|
119
|
+
logger.warn('notify: service %s cannot say where a conversation lives (no topics method); set notify.to', via)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
const place = await conversationPlace(ctx, await service.topics(), info.agent?.session?.header)
|
|
123
|
+
if (place === undefined) {
|
|
124
|
+
logger.info('notify: session %s has no place in %s; no message sent', info.sessionId, via)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
request = {
|
|
128
|
+
to: String(place.topic.threadId),
|
|
129
|
+
...place.topic.chatId == null ? {} : { chat: place.topic.chatId },
|
|
130
|
+
text: switchText(info, place.relation),
|
|
131
|
+
files: [],
|
|
132
|
+
from,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
await service.send(request)
|
|
136
|
+
} catch (error) {
|
|
137
|
+
logger.warn('notify through %s failed: %s', via, error instanceof Error ? error.message : String(error))
|
|
138
|
+
}
|
|
139
|
+
}
|