@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 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, and the address to pass it. |
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 set: the plugin looks up the service named by `via` at the time of the switch and calls its `send({ to, text, files: [], from: { label: 'dsh-fallback-models', sessionId } })` with one short message (worded apart for a refusal). Any plugin that provides such a service can receive these messages, for example a Telegram plugin's topic service with a topic's thread id as `to`. A missing service or a failed send is logged and changes nothing else.
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.2.1",
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('Address that service understands, such as a topic\'s thread id.'),
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 `send({ to, text, from })` call to
23
- // it (for example a Telegram plugin's topic service).
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
- const via = typeof notify?.via === 'string' ? notify.via.trim() : ''
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
+ }