ds-bot 0.1.2 → 0.1.4
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 -3
- package/client.js +2164 -1102
- package/index.js +10 -1
- package/package.json +6 -4
- package/src/host/approvals.js +69 -0
- package/src/host/capabilities.js +521 -0
- package/src/host/channel.js +26 -4
- package/src/host/constants.js +12 -1
- package/src/host/delivery.js +34 -8
- package/src/host/diagnostics.js +7 -5
- package/src/host/groups.js +296 -59
- package/src/host/home-lock.js +16 -10
- package/src/host/images.js +37 -0
- package/src/host/memory.js +27 -16
- package/src/host/parts.js +94 -21
- package/src/host/prompt.js +7 -3
- package/src/host/resume.js +247 -0
- package/src/host/roster.js +110 -56
- package/src/host/secrets.js +9 -1
- package/src/host/store.js +129 -31
- package/src/host/time-context.js +46 -0
- package/src/host/tools.js +182 -29
- package/src/host/turns.js +263 -39
- package/src/host/update.js +5 -1
- package/src/shared/mentions.js +65 -0
- package/web-search.js +23 -7
package/src/host/turns.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// triggered, the waiters and inboxes of group rounds, and routing a finished turn's reply.
|
|
3
3
|
//
|
|
4
4
|
// Turn tracking: which turns were triggered by another Bot, and what they said.
|
|
5
|
-
import { REPLY_KIND, SOURCE_KIND } from './constants.js'
|
|
5
|
+
import { REFERENCE_KIND, REPLY_KIND, SOURCE_KIND } from './constants.js'
|
|
6
6
|
import { textOf } from './text.js'
|
|
7
7
|
import { reminderText } from './schedules.js'
|
|
8
8
|
|
|
@@ -21,18 +21,81 @@ export function turnFailure(error) {
|
|
|
21
21
|
|
|
22
22
|
export const failureLine = failure => `${failure.code}${failure.status ? ` ${failure.status}` : ''}${failure.message ? `: ${failure.message.slice(0, 200)}` : ''}`
|
|
23
23
|
|
|
24
|
+
// A group's own Session never reaches a model, so the snapshot DSH prepared there
|
|
25
|
+
// rides with the user's queued message to the members who answer it.
|
|
26
|
+
export function attachReference(queued, source, text) {
|
|
27
|
+
const post = queued?.findLast(item => item.botId === undefined && item.kickoff !== true)
|
|
28
|
+
if (post === undefined || text === '') return
|
|
29
|
+
const labels = (Array.isArray(source?.references) ? source.references : [])
|
|
30
|
+
.map(reference => reference?.label).filter(label => typeof label === 'string' && label !== '')
|
|
31
|
+
post.references = [...(post.references ?? []), { labels, text }]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A tool call some models write into their text instead of making it (the XML-like
|
|
35
|
+
// form of their own chat template).
|
|
36
|
+
const WRITTEN_CALL = /<tool_call>\s*<function=|<function=[\w.-]+>\s*<parameter=/
|
|
37
|
+
export const writesToolCall = text => typeof text === 'string' && WRITTEN_CALL.test(text)
|
|
38
|
+
export const stripWrittenCalls = text => text
|
|
39
|
+
.replace(/<tool_call>[\s\S]*?(?:<\/tool_call>|$)/g, '')
|
|
40
|
+
.replace(/<function=[\w.-]+>[\s\S]*?(?:<\/function>|$)/g, '')
|
|
41
|
+
.trim()
|
|
42
|
+
|
|
43
|
+
const RETRY_TEXT = 'Your last reply wrote a tool call as plain text (<tool_call>…), so it did not run and nothing happened. Call the tool through the tool interface now, then reply as you meant to.'
|
|
44
|
+
|
|
24
45
|
export function install(rt) {
|
|
25
46
|
const { ctx, state } = rt
|
|
26
47
|
|
|
27
|
-
|
|
48
|
+
// The retry goes where the turn's trigger came from: a group round's waiter or a
|
|
49
|
+
// Bot's request keeps its exchange; anything else is a nudge in the Bot's own chat.
|
|
50
|
+
const retryWrittenCall = (id, info) => {
|
|
51
|
+
const keeps = info !== undefined && (info.role === 'room' || expectsReply(info))
|
|
52
|
+
return rt.deliver({
|
|
53
|
+
...(keeps && info.senderSessionId ? { fromId: info.senderSessionId } : {}),
|
|
54
|
+
toId: rt.selfOf(id),
|
|
55
|
+
sessionId: id,
|
|
56
|
+
role: keeps ? info.role : 'retry',
|
|
57
|
+
...(keeps && info.roomId ? { roomId: info.roomId } : {}),
|
|
58
|
+
hop: keeps ? info.hop ?? 0 : 0,
|
|
59
|
+
...(keeps && info.exchangeId ? { exchangeId: info.exchangeId } : {}),
|
|
60
|
+
...(keeps && info.audience ? { audience: info.audience } : {}),
|
|
61
|
+
...(keeps && info.askedBy ? { askedBy: info.askedBy } : {}),
|
|
62
|
+
...(keeps && info.senderRoomId ? { fromRoomId: info.senderRoomId } : {}),
|
|
63
|
+
note: 'your tool call did not run',
|
|
64
|
+
retried: true,
|
|
65
|
+
text: RETRY_TEXT,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @type {Map<string, {turn?:number, pending?:any, info?:any, texts:string[], opened?:boolean, joined?:{info:any, from:number}[]}>} */
|
|
28
70
|
const live = new Map()
|
|
29
|
-
/** @type {Map<string, {resolve:(text:string)=>void}>} member turn waiters keyed by exchange id */
|
|
71
|
+
/** @type {Map<string, {sessionId:string, resolve:(text:string|null)=>void, fail:(failure:any)=>void, touch:()=>void, approval:()=>void}>} member turn waiters keyed by exchange id */
|
|
30
72
|
const roomWaiters = new Map()
|
|
31
73
|
/** @type {Map<string, {roomId:string, replies:any[], members:string[], speaking:string|null}>} */
|
|
32
74
|
const roomProgress = new Map()
|
|
33
|
-
/** @type {Map<string, {text:string, botId?:string, name?:string, reportBack?:boolean, hop?:number, kickoff?:boolean}[]>} messages a room's next relay posts */
|
|
75
|
+
/** @type {Map<string, {text:string, botId?:string, name?:string, reportBack?:boolean, hop?:number, kickoff?:boolean, references?:{labels:string[], text:string}[]}[]>} messages a room's next relay posts */
|
|
34
76
|
const roomInbox = new Map()
|
|
35
77
|
const queueRoomMessage = (roomId, item) => roomInbox.set(roomId, [...(roomInbox.get(roomId) ?? []), item])
|
|
78
|
+
// Member turns of a group round the user stopped: what they end with stays out of the group.
|
|
79
|
+
const stoppedRoomTurns = new Set()
|
|
80
|
+
|
|
81
|
+
// A stopped round stops the member work it started: the turn that answers it, or the
|
|
82
|
+
// delivery still waiting for the member's Session. An earlier turn there keeps going.
|
|
83
|
+
// An exchange whose turn already ended stays in the set, so it is bounded.
|
|
84
|
+
const STOPPED_KEPT = 200
|
|
85
|
+
async function stopRoomTurn(sessionId, exchangeId) {
|
|
86
|
+
stoppedRoomTurns.add(exchangeId)
|
|
87
|
+
if (stoppedRoomTurns.size > STOPPED_KEPT) stoppedRoomTurns.delete(stoppedRoomTurns.values().next().value)
|
|
88
|
+
const agent = await rt.resolveAgent(sessionId).catch(() => undefined)
|
|
89
|
+
if (agent === undefined) return
|
|
90
|
+
const entry = live.get(sessionId)
|
|
91
|
+
if (entry?.turn !== undefined && entry.info?.exchangeId === exchangeId) {
|
|
92
|
+
agent.cancel({ kind: 'hook', reason: 'ds-bot/stopped' })
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
for (const message of [...(agent.inbox?.nextStep ?? []), ...(agent.inbox?.nextTurn ?? [])]) {
|
|
96
|
+
if (message.source?.exchangeId === exchangeId && agent.inbox.remove(message.id)) stoppedRoomTurns.delete(exchangeId)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
36
99
|
|
|
37
100
|
const liveOf = id => {
|
|
38
101
|
let entry = live.get(id)
|
|
@@ -45,7 +108,7 @@ export function install(rt) {
|
|
|
45
108
|
/** @type {Record<string, string>} */
|
|
46
109
|
let activity = {}
|
|
47
110
|
const activityOf = (tool) => {
|
|
48
|
-
if (/^(message_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(tool)) return 'sending'
|
|
111
|
+
if (/^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(tool)) return 'sending'
|
|
49
112
|
if (tool === 'group_relay') return 'orbit'
|
|
50
113
|
if (/search|fetch|browse|browser|read|grep|glob|list/i.test(tool)) return 'searching'
|
|
51
114
|
return 'working'
|
|
@@ -63,26 +126,33 @@ export function install(rt) {
|
|
|
63
126
|
if (!turns.includes(turn)) {
|
|
64
127
|
turns.push(turn)
|
|
65
128
|
if (turns.length > 400) turns.splice(0, turns.length - 400)
|
|
66
|
-
void rt.save()
|
|
129
|
+
void rt.save().catch(() => {})
|
|
67
130
|
}
|
|
68
131
|
}
|
|
69
132
|
const unmarkExchangeTurn = (sessionId, turn) => {
|
|
70
133
|
const turns = state.exchangeTurns[sessionId] ?? []
|
|
71
134
|
if (!turns.includes(turn)) return
|
|
72
135
|
state.exchangeTurns[sessionId] = turns.filter(other => other !== turn)
|
|
73
|
-
void rt.save()
|
|
136
|
+
void rt.save().catch(() => {})
|
|
74
137
|
}
|
|
75
138
|
|
|
76
139
|
// A request whose audience is the user is answered in the recipient's own chat: that
|
|
77
140
|
// turn stays visible there and its reply is not routed back to the sender.
|
|
78
141
|
const answersUser = info => info.role === 'request' && info.audience === 'user'
|
|
142
|
+
// Roles whose turn owes the sender a reply: a 'stopped' notice asks the Bot to
|
|
143
|
+
// acknowledge, exactly like a request.
|
|
144
|
+
const expectsReply = info => info.role === 'request' || info.role === 'stopped'
|
|
79
145
|
|
|
80
146
|
// A group Session is hidden as a whole, so its turns need no marking. A handoff
|
|
81
147
|
// package that only opens a part is acknowledged with a fixed line, also hidden.
|
|
82
148
|
const claimTrigger = (sessionId, entry, info) => {
|
|
83
149
|
entry.info = info
|
|
150
|
+
// A turn that was stopped for a part switch brings its joined requests along.
|
|
151
|
+
if (Array.isArray(info.joined) && info.joined.length > 0) {
|
|
152
|
+
entry.joined = info.joined.filter(item => item?.info !== undefined && item.info !== null)
|
|
153
|
+
}
|
|
84
154
|
if (rt.groupOwners.has(sessionId)) return
|
|
85
|
-
if ((info
|
|
155
|
+
if ((expectsReply(info) && !answersUser(info)) || info.role === 'room' || (info.role === 'handoff' && info.resume !== true)) {
|
|
86
156
|
markExchangeTurn(sessionId, entry.turn)
|
|
87
157
|
}
|
|
88
158
|
}
|
|
@@ -102,9 +172,52 @@ export function install(rt) {
|
|
|
102
172
|
if (job.roomId === undefined) merged[job.botId] = 'thinking'
|
|
103
173
|
else merged[job.botId] ??= 'thinking'
|
|
104
174
|
}
|
|
175
|
+
for (const approval of rt.approvalsView?.() ?? []) merged[approval.botId] = 'alert'
|
|
105
176
|
return merged
|
|
106
177
|
}
|
|
107
178
|
|
|
179
|
+
// Per Bot, the turn it is running and its current tool; its own chat wins over a group.
|
|
180
|
+
const workView = () => {
|
|
181
|
+
const bots = {}
|
|
182
|
+
for (const [id, entry] of live) {
|
|
183
|
+
if (entry.turn === undefined) continue
|
|
184
|
+
const owner = rt.groupOwners.get(id)
|
|
185
|
+
const botId = owner?.botId ?? rt.chatOwners.get(id)
|
|
186
|
+
if (botId === undefined || (owner !== undefined && bots[botId] !== undefined)) continue
|
|
187
|
+
bots[botId] = { since: entry.since, ...(entry.tool ? { tool: entry.tool } : {}), ...(owner ? { roomId: owner.roomId } : {}) }
|
|
188
|
+
}
|
|
189
|
+
return bots
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Per group, the members whose turn there is running; the round's current speaker is
|
|
193
|
+
// marked, since its round shows it.
|
|
194
|
+
const roomWorkView = () => {
|
|
195
|
+
const speaking = new Set([...roomProgress.values()].map(round => `${round.roomId}:${round.speaking}`))
|
|
196
|
+
const rooms = {}
|
|
197
|
+
for (const [id, entry] of live) {
|
|
198
|
+
const owner = rt.groupOwners.get(id)
|
|
199
|
+
if (entry.turn === undefined || owner === undefined) continue
|
|
200
|
+
rooms[owner.roomId] = [...(rooms[owner.roomId] ?? []), {
|
|
201
|
+
botId: owner.botId,
|
|
202
|
+
since: entry.since,
|
|
203
|
+
...(entry.tool ? { tool: entry.tool } : {}),
|
|
204
|
+
...(speaking.has(`${owner.roomId}:${owner.botId}`) ? { speaking: true } : {}),
|
|
205
|
+
}]
|
|
206
|
+
}
|
|
207
|
+
return rooms
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// What a running tool call is about, for an approval card: its own description and
|
|
211
|
+
// the command, path or address it acts on.
|
|
212
|
+
const callDetail = (sessionId, callId) => {
|
|
213
|
+
let args
|
|
214
|
+
try { args = JSON.parse(live.get(sessionId)?.calls?.get(callId) ?? '{}') } catch { args = {} }
|
|
215
|
+
const first = keys => keys.map(key => args?.[key]).find(value => typeof value === 'string' && value.trim() !== '')
|
|
216
|
+
const text = first(['description', 'justification'])
|
|
217
|
+
const code = first(['command', 'cmd', 'file_path', 'path', 'url', 'query'])
|
|
218
|
+
return { ...(text ? { text: text.trim().slice(0, 300) } : {}), ...(code ? { code: code.trim().slice(0, 400) } : {}) }
|
|
219
|
+
}
|
|
220
|
+
|
|
108
221
|
// Where a Session speaks, for the diagnostics log.
|
|
109
222
|
const placeOf = (id) => {
|
|
110
223
|
const room = rt.roomOf(rt.groupOwners.get(id)?.roomId) ?? rt.roomOf(id)
|
|
@@ -128,21 +241,32 @@ export function install(rt) {
|
|
|
128
241
|
if (id === undefined) return
|
|
129
242
|
if (!rt.isBotSession(id) && !rt.roomOf(id)) return
|
|
130
243
|
const entry = liveOf(id)
|
|
244
|
+
if (rt.groupOwners.has(id)) rt.touchRoomTurn?.(id)
|
|
131
245
|
switch (event.type) {
|
|
132
|
-
case 'tool/call':
|
|
133
|
-
|
|
246
|
+
case 'tool/call': {
|
|
247
|
+
const name = String(event.data?.name ?? '')
|
|
248
|
+
setActivity(id, name === 'ask_user' ? undefined : activityOf(name))
|
|
249
|
+
entry.tool = name
|
|
250
|
+
if (rt.groupOwners.has(id)) rt.workRoomTurn?.(id, name)
|
|
251
|
+
if (typeof event.data?.callId === 'string') (entry.calls ??= new Map()).set(event.data.callId, String(event.data.arguments ?? ''))
|
|
134
252
|
break
|
|
253
|
+
}
|
|
135
254
|
case 'tool/result':
|
|
136
255
|
if (entry.turn !== undefined) setActivity(id, 'thinking')
|
|
256
|
+
entry.tool = undefined
|
|
257
|
+
entry.calls?.delete(event.data?.callId)
|
|
137
258
|
break
|
|
138
259
|
case 'turn/start':
|
|
139
260
|
setActivity(id, 'thinking')
|
|
140
261
|
entry.turn = event.data.turn
|
|
262
|
+
entry.since = event.time ?? Date.now()
|
|
141
263
|
entry.texts = []
|
|
142
264
|
entry.info = undefined
|
|
143
265
|
// A turn turned away before its first step may end without a turn/end.
|
|
144
266
|
entry.switched = false
|
|
145
267
|
entry.answers = false
|
|
268
|
+
entry.opened = false
|
|
269
|
+
entry.joined = []
|
|
146
270
|
if (entry.pending !== undefined) {
|
|
147
271
|
claimTrigger(id, entry, entry.pending)
|
|
148
272
|
entry.pending = undefined
|
|
@@ -153,6 +277,14 @@ export function install(rt) {
|
|
|
153
277
|
// A message that shares a step with a bare handoff package is what the turn
|
|
154
278
|
// answers, so the turn is not a hidden acknowledgement.
|
|
155
279
|
const bare = info => info?.handoff === true && info.resume !== true
|
|
280
|
+
// Whether the turn was already opened by an earlier message decides what a
|
|
281
|
+
// Bot relay that lands inside it may claim: the first message of a turn
|
|
282
|
+
// still claims it as today, a later one only joins it.
|
|
283
|
+
const opened = entry.opened === true
|
|
284
|
+
if (entry.turn !== undefined && !bare(source)
|
|
285
|
+
&& (source?.kind === 'user' || source?.kind === REPLY_KIND || source?.kind === 'schedule' || source?.kind === SOURCE_KIND)) {
|
|
286
|
+
entry.opened = true
|
|
287
|
+
}
|
|
156
288
|
if (entry.turn !== undefined && (source?.kind === 'user' || source?.kind === REPLY_KIND || source?.kind === SOURCE_KIND)) {
|
|
157
289
|
if (bare(source)) {
|
|
158
290
|
if (entry.answers) break
|
|
@@ -171,15 +303,36 @@ export function install(rt) {
|
|
|
171
303
|
name: source.senderName,
|
|
172
304
|
reportBack: source.reportBack === true,
|
|
173
305
|
hop: source.hop ?? 0,
|
|
306
|
+
...(source.late ? { late: true } : {}),
|
|
307
|
+
...(source.quiet ? { quiet: true } : {}),
|
|
308
|
+
...(source.failed ? { failed: source.failed } : {}),
|
|
309
|
+
...(source.resumed ? { resumed: true } : {}),
|
|
310
|
+
...(source.again ? { again: true } : {}),
|
|
311
|
+
...(source.stopped ? { stopped: true } : {}),
|
|
312
|
+
...(source.askedBy ? { askedBy: source.askedBy } : {}),
|
|
174
313
|
})
|
|
175
314
|
} else if (source?.kind === SOURCE_KIND && source.role === 'kickoff' && rt.roomOf(id)) {
|
|
176
315
|
queueRoomMessage(id, { text: '', kickoff: true })
|
|
177
316
|
} else if (source?.kind === SOURCE_KIND) {
|
|
178
|
-
if (entry.turn
|
|
179
|
-
else entry
|
|
317
|
+
if (entry.turn === undefined) entry.pending = source
|
|
318
|
+
else if (!opened) claimTrigger(id, entry, source)
|
|
319
|
+
else if (expectsReply(source) && !answersUser(source)
|
|
320
|
+
&& source.exchangeId !== entry.info?.exchangeId
|
|
321
|
+
&& !(entry.joined ?? []).some(joined => joined.info.exchangeId === source.exchangeId)) {
|
|
322
|
+
// A request that lands inside a running turn steers that work but is
|
|
323
|
+
// answered in a hidden turn of its own once the work is done, so the
|
|
324
|
+
// sender never gets the turn's text. A failed-switch resume message
|
|
325
|
+
// re-sends the trigger's own source; the same exchangeId must not join twice.
|
|
326
|
+
const text = textOf(event.data.content).replace(/^\[[^\]\n]*\]\n?/, '')
|
|
327
|
+
entry.joined = [...(entry.joined ?? []), { info: source, text }]
|
|
328
|
+
}
|
|
329
|
+
// A mid-turn relay that owes no reply only informs the turn: it neither
|
|
330
|
+
// claims the running turn nor queues as the next one's trigger.
|
|
180
331
|
} else if (source?.kind === 'schedule' && rt.roomOf(id)) {
|
|
181
332
|
// A group's scheduled task reaches the group as the user's message.
|
|
182
333
|
queueRoomMessage(id, { text: reminderText(textOf(event.data.content)) })
|
|
334
|
+
} else if (source?.kind === REFERENCE_KIND && rt.roomOf(id)) {
|
|
335
|
+
attachReference(roomInbox.get(id), source, textOf(event.data.content))
|
|
183
336
|
} else if (source?.kind === 'user' || source?.kind === REPLY_KIND) {
|
|
184
337
|
if (rt.roomOf(id) && source.kind === 'user') {
|
|
185
338
|
const images = (Array.isArray(event.data.content) ? event.data.content : [])
|
|
@@ -191,60 +344,131 @@ export function install(rt) {
|
|
|
191
344
|
// Any user message answers or supersedes the open question card.
|
|
192
345
|
if (state.questions[id] !== undefined) {
|
|
193
346
|
delete state.questions[id]
|
|
194
|
-
void rt.save()
|
|
347
|
+
void rt.save().catch(() => {})
|
|
195
348
|
}
|
|
196
349
|
}
|
|
197
350
|
break
|
|
198
351
|
}
|
|
199
352
|
case 'assistant/message': {
|
|
200
|
-
const
|
|
353
|
+
const content = event.data?.message?.content
|
|
354
|
+
const text = textOf(content)
|
|
201
355
|
if (text !== '') entry.texts.push(text)
|
|
356
|
+
entry.lastText = text
|
|
357
|
+
entry.lastCalled = Array.isArray(content) && content.some(block => block?.type === 'tool-call')
|
|
202
358
|
break
|
|
203
359
|
}
|
|
204
360
|
case 'turn/end': {
|
|
205
361
|
setActivity(id, undefined)
|
|
206
362
|
const info = entry.info
|
|
207
|
-
const
|
|
363
|
+
const joined = entry.joined ?? []
|
|
364
|
+
const texts = entry.texts
|
|
365
|
+
const { lastText, lastCalled } = entry
|
|
208
366
|
// A turn stopped for a switch goes on in the next part, which answers instead.
|
|
209
367
|
const switched = entry.switched === true
|
|
210
368
|
entry.turn = undefined
|
|
211
369
|
entry.info = undefined
|
|
212
370
|
entry.texts = []
|
|
213
371
|
entry.switched = false
|
|
372
|
+
entry.joined = []
|
|
373
|
+
entry.since = undefined
|
|
374
|
+
entry.tool = undefined
|
|
375
|
+
entry.calls = undefined
|
|
376
|
+
entry.lastText = undefined
|
|
377
|
+
entry.lastCalled = undefined
|
|
214
378
|
const reason = event.data?.reason
|
|
215
379
|
const failure = reason?.kind === 'error' && !switched ? turnFailure(reason.error) : undefined
|
|
216
380
|
if (failure !== undefined) noteTurnFailure(id, failure)
|
|
217
|
-
if (
|
|
218
|
-
if
|
|
381
|
+
if (switched) break
|
|
382
|
+
// A model that writes its tool call as text ends the turn as if it were done,
|
|
383
|
+
// though nothing ran. It gets one more turn on the same trigger, so a waiting
|
|
384
|
+
// round or sender gets the real answer; a second time, the text goes on without
|
|
385
|
+
// the call.
|
|
386
|
+
const wroteCall = failure === undefined && reason?.kind === 'completed' && lastCalled !== true && writesToolCall(lastText)
|
|
387
|
+
const retry = wroteCall && info?.retried !== true && !(info?.role === 'room' && stoppedRoomTurns.has(info.exchangeId))
|
|
388
|
+
if (retry) void retryWrittenCall(id, info).catch(error => rt.warn('ds-bot: asking %s to run its written tool call failed: %s', id, error))
|
|
389
|
+
const replyText = () => {
|
|
390
|
+
const reply = (wroteCall ? texts.map(stripWrittenCalls) : texts).join('\n\n').trim()
|
|
391
|
+
return wroteCall && reply === '' ? 'Tried to run a tool but wrote the call as text, so it did not run.' : reply
|
|
392
|
+
}
|
|
393
|
+
// Whoever the turn answers gets the same three-way rule: a failure notice,
|
|
394
|
+
// the turn's text (or a stopped-before-answer line), or nothing.
|
|
395
|
+
const answer = (source, reply) => {
|
|
396
|
+
if (!expectsReply(source) || answersUser(source)) return
|
|
397
|
+
const name = rt.botOf(rt.selfOf(id))?.name ?? 'The other Bot'
|
|
398
|
+
// A sender that wrote from a group gets the reply in its Session there, while
|
|
399
|
+
// it is still a member.
|
|
400
|
+
const back = rt.roomOf(source.senderRoomId)?.sessions?.[source.senderSessionId]
|
|
401
|
+
if (failure !== undefined) {
|
|
402
|
+
// The sender was told a reply comes later; without this it would wait forever.
|
|
403
|
+
void rt.deliver({
|
|
404
|
+
fromId: rt.selfOf(id),
|
|
405
|
+
toId: source.senderSessionId,
|
|
406
|
+
...(back ? { sessionId: back } : {}),
|
|
407
|
+
text: `${name} could not answer your message: its model call failed (${failureLine(failure)}). Tell the user if it matters; do not retry right away.`,
|
|
408
|
+
role: 'reply',
|
|
409
|
+
hop: (source.hop ?? 0) + 1,
|
|
410
|
+
replyTo: source.exchangeId,
|
|
411
|
+
}).catch(error => rt.warn('ds-bot: failure notice delivery failed: %s', error))
|
|
412
|
+
} else if (reply !== '' || reason?.kind === 'aborted') {
|
|
413
|
+
// A turn stopped before it could answer still owes the sender a line,
|
|
414
|
+
// or it would wait forever for a reply that never comes.
|
|
415
|
+
void rt.deliver({
|
|
416
|
+
fromId: rt.selfOf(id),
|
|
417
|
+
toId: source.senderSessionId,
|
|
418
|
+
...(back ? { sessionId: back } : {}),
|
|
419
|
+
text: reply !== '' ? reply : `${name} was stopped before it could answer your message.`,
|
|
420
|
+
role: 'reply',
|
|
421
|
+
hop: (source.hop ?? 0) + 1,
|
|
422
|
+
replyTo: source.exchangeId,
|
|
423
|
+
}).catch(error => rt.warn('ds-bot: reply delivery failed: %s', error))
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
// A retried turn answers the same waiter or sender, so this one passes nothing on.
|
|
427
|
+
if (!retry && info?.role === 'room') {
|
|
219
428
|
const waiter = roomWaiters.get(info.exchangeId)
|
|
220
429
|
roomWaiters.delete(info.exchangeId)
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
430
|
+
const stopped = stoppedRoomTurns.delete(info.exchangeId)
|
|
431
|
+
if (waiter !== undefined) {
|
|
432
|
+
if (failure !== undefined) waiter.fail(failure)
|
|
433
|
+
else waiter.resolve(reason?.kind === 'aborted' ? null : replyText())
|
|
434
|
+
} else if (!stopped && reason?.kind !== 'aborted') {
|
|
435
|
+
// The round moved on without this member; how its turn ends still reaches the group.
|
|
436
|
+
if (failure !== undefined) void rt.lateRoomFailure?.(id, info, failure)
|
|
437
|
+
else void rt.lateRoomReply?.(id, info, replyText())
|
|
438
|
+
}
|
|
439
|
+
} else if (!retry && info !== undefined) {
|
|
440
|
+
// A group Session speaks only in its group: the turn that takes a reply to a
|
|
441
|
+
// message it sent from there posts what it ends with.
|
|
442
|
+
if (rt.groupOwners.has(id) && (info.role === 'reply' || info.role === 'retry')) {
|
|
443
|
+
if (failure !== undefined) void rt.lateRoomFailure?.(id, info, failure)
|
|
444
|
+
else if (reason?.kind !== 'aborted') void rt.lateRoomReply?.(id, info, replyText())
|
|
445
|
+
} else {
|
|
446
|
+
answer(info, replyText())
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
for (const request of joined) {
|
|
450
|
+
// A failed or stopped turn closes the request like any other; a finished
|
|
451
|
+
// one hands it back to the Bot to answer next.
|
|
452
|
+
if (failure !== undefined || reason?.kind === 'aborted') {
|
|
453
|
+
answer(request.info, '')
|
|
454
|
+
continue
|
|
455
|
+
}
|
|
235
456
|
void rt.deliver({
|
|
236
|
-
fromId:
|
|
237
|
-
toId:
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
457
|
+
fromId: request.info.senderSessionId,
|
|
458
|
+
toId: rt.selfOf(id),
|
|
459
|
+
sessionId: id,
|
|
460
|
+
text: request.text,
|
|
461
|
+
role: 'request',
|
|
462
|
+
hop: request.info.hop ?? 0,
|
|
463
|
+
exchangeId: request.info.exchangeId,
|
|
464
|
+
...(request.info.senderRoomId ? { fromRoomId: request.info.senderRoomId } : {}),
|
|
465
|
+
deferred: true,
|
|
466
|
+
}).catch(error => rt.warn('ds-bot: deferred request delivery failed: %s', error))
|
|
243
467
|
}
|
|
244
468
|
break
|
|
245
469
|
}
|
|
246
470
|
}
|
|
247
471
|
})
|
|
248
472
|
|
|
249
|
-
Object.assign(rt, { live, liveOf, roomWaiters, roomProgress, roomInbox, activityView })
|
|
473
|
+
Object.assign(rt, { live, liveOf, roomWaiters, roomProgress, roomInbox, activityView, workView, roomWorkView, callDetail, stopRoomTurn })
|
|
250
474
|
}
|
package/src/host/update.js
CHANGED
|
@@ -161,8 +161,12 @@ export function install(rt) {
|
|
|
161
161
|
const release = await fetchJson(`https://api.github.com/repos/${source.owner}/${source.repo}/releases/latest`, { 'user-agent': `${PKG.name}/${PKG.version}` })
|
|
162
162
|
const version = cleanVersion(release?.tag_name)
|
|
163
163
|
if (!version) return null
|
|
164
|
+
// The API answers through redirects, so the release's html_url names the
|
|
165
|
+
// repository's current address; the install moves to it.
|
|
166
|
+
const current = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/releases\//.exec(release.html_url ?? '')
|
|
167
|
+
const [owner, repo] = current ? [current[1], current[2]] : [source.owner, source.repo]
|
|
164
168
|
const fragment = [release.tag_name, ...source.rest].join('&')
|
|
165
|
-
return { version, spec: `github:${
|
|
169
|
+
return { version, spec: `github:${owner}/${repo}#${fragment}` }
|
|
166
170
|
}
|
|
167
171
|
|
|
168
172
|
async function runCheck() {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// "@Name" is read by one rule everywhere: the group host picks who answers with it,
|
|
2
|
+
// and the client draws the same mentions in the bubbles, so what the user sees called
|
|
3
|
+
// is what gets called. Pure, so both sides import it.
|
|
4
|
+
|
|
5
|
+
export const ALL_WORDS = ['all', 'everyone', '所有人', '全体']
|
|
6
|
+
// A letter, digit, "_" or "-" after a name means a longer word ("@Mi" in "@MiniMax"),
|
|
7
|
+
// and "/", "\" or a "." that goes on into a word mean a path: DSH writes a file
|
|
8
|
+
// reference as "@docs/guide.md", which must not call a Bot named Docs. A letter,
|
|
9
|
+
// digit, "_", "." or "/" before "@" means an address ("a@b", "medium.com/@Writer").
|
|
10
|
+
const NAME_GOES_ON = /^(?:[a-z0-9_\-/\\]|\.[a-z0-9_-])/i
|
|
11
|
+
const ADDRESS_BEFORE = /[a-z0-9_./]/i
|
|
12
|
+
// Code and Markdown links, with or without a title, are literal text.
|
|
13
|
+
const LITERAL = /```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\s]*(?:\s+(?:"[^"\n]*"|'[^'\n]*'))?\)/g
|
|
14
|
+
|
|
15
|
+
const fitsAt = (text, at, word) => text.slice(at, at + word.length).toLowerCase() === word.toLowerCase()
|
|
16
|
+
&& !NAME_GOES_ON.test(text.slice(at + word.length))
|
|
17
|
+
const overlaps = (spans, start, end) => spans.some(span => start < span.end && span.start < end)
|
|
18
|
+
|
|
19
|
+
// What a Bot may be named: something "@" can call without a second reading.
|
|
20
|
+
export function nameProblem(name) {
|
|
21
|
+
const text = String(name ?? '').trim()
|
|
22
|
+
if (text === '') return 'A Bot needs a name'
|
|
23
|
+
if (/[@`\r\n]/.test(text)) return 'A Bot name cannot contain @, ` or a line break'
|
|
24
|
+
if (ALL_WORDS.includes(text.toLowerCase())) return `"${text}" calls everyone in a group, so it cannot be a Bot name`
|
|
25
|
+
return undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// DSH writes each referenced conversation into the message once as "@label"; that one
|
|
29
|
+
// occurrence per listed label is a reference, not a call. Longest labels go first so
|
|
30
|
+
// "@K notes" is never read as the label "K".
|
|
31
|
+
export function referenceSpans(text, labels = []) {
|
|
32
|
+
const spans = []
|
|
33
|
+
for (const label of [...labels].sort((a, b) => b.length - a.length)) {
|
|
34
|
+
const mention = `@${label}`
|
|
35
|
+
for (let at = text.indexOf(mention); at !== -1; at = text.indexOf(mention, at + 1)) {
|
|
36
|
+
const end = at + mention.length
|
|
37
|
+
if ((at > 0 && ADDRESS_BEFORE.test(text[at - 1])) || NAME_GOES_ON.test(text.slice(end)) || overlaps(spans, at, end)) continue
|
|
38
|
+
spans.push({ start: at, end, label })
|
|
39
|
+
break
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return spans.sort((a, b) => a.start - b.start)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Every "@Name" and "@all" outside code, links and references, in order. Longest names
|
|
46
|
+
// match first, so "@新 Bot 2" is never "@新 Bot"; a name may hold spaces.
|
|
47
|
+
export function mentionSpans(text, bots, { references = [] } = {}) {
|
|
48
|
+
const value = String(text ?? '')
|
|
49
|
+
const skip = [
|
|
50
|
+
...[...value.matchAll(LITERAL)].map(match => ({ start: match.index, end: match.index + match[0].length })),
|
|
51
|
+
...referenceSpans(value, references),
|
|
52
|
+
]
|
|
53
|
+
const byLength = bots.filter(bot => bot?.name).sort((a, b) => b.name.length - a.name.length)
|
|
54
|
+
const spans = []
|
|
55
|
+
for (let at = value.indexOf('@'); at !== -1; at = value.indexOf('@', at + 1)) {
|
|
56
|
+
if ((at > 0 && ADDRESS_BEFORE.test(value[at - 1])) || overlaps(skip, at, at + 1)) continue
|
|
57
|
+
const bot = byLength.find(candidate => fitsAt(value, at + 1, candidate.name))
|
|
58
|
+
const all = bot ? undefined : ALL_WORDS.find(word => fitsAt(value, at + 1, word))
|
|
59
|
+
if (!bot && !all) continue
|
|
60
|
+
const end = at + 1 + (bot ? bot.name.length : all.length)
|
|
61
|
+
spans.push(bot ? { start: at, end, bot } : { start: at, end, all: true })
|
|
62
|
+
at = end - 1
|
|
63
|
+
}
|
|
64
|
+
return spans
|
|
65
|
+
}
|
package/web-search.js
CHANGED
|
@@ -472,6 +472,15 @@ export const ENGINES = {
|
|
|
472
472
|
|
|
473
473
|
const aborted = signal => new WebError('web search aborted', 'WEB_ABORTED', { cause: signal?.reason })
|
|
474
474
|
|
|
475
|
+
// AbortSignal.timeout's wait is unref'd, so under Node 22's test runner a pending subtest
|
|
476
|
+
// is cancelled as soon as nothing ref'd holds the loop — and a mocked fetch holds nothing.
|
|
477
|
+
// A plain setTimeout keeps the loop honest; the abort reason stays a TimeoutError.
|
|
478
|
+
const timeoutAfter = (ms) => {
|
|
479
|
+
const timeout = new AbortController()
|
|
480
|
+
const timer = setTimeout(() => timeout.abort(new DOMException('The operation timed out.', 'TimeoutError')), ms)
|
|
481
|
+
return { timeout, timer }
|
|
482
|
+
}
|
|
483
|
+
|
|
475
484
|
function checkSearchApi(api, index) {
|
|
476
485
|
const where = `web-search-auto: searchApis[${index}]`
|
|
477
486
|
if (!isRecord(api)) throw new Error(`${where} must be an object`)
|
|
@@ -551,7 +560,7 @@ export function createAutoSearchProvider(deps, config = {}) {
|
|
|
551
560
|
|
|
552
561
|
async function attempt(candidate, route, credential, request, signal, budget, known) {
|
|
553
562
|
const call = candidate.request({ query: request.query, maxResults: request.maxResults })
|
|
554
|
-
const timeout =
|
|
563
|
+
const { timeout, timer } = timeoutAfter(budget)
|
|
555
564
|
let response
|
|
556
565
|
let text
|
|
557
566
|
try {
|
|
@@ -561,12 +570,14 @@ export function createAutoSearchProvider(deps, config = {}) {
|
|
|
561
570
|
redirect: 'error',
|
|
562
571
|
headers: { 'content-type': 'application/json', accept: 'application/json', 'user-agent': USER_AGENT, ...route.headers, ...call.headers, ...credential.headers(candidate) },
|
|
563
572
|
body: JSON.stringify(call.body),
|
|
564
|
-
signal: signal === undefined ? timeout : AbortSignal.any([signal, timeout]),
|
|
573
|
+
signal: signal === undefined ? timeout.signal : AbortSignal.any([signal, timeout.signal]),
|
|
565
574
|
})
|
|
566
575
|
text = await response.text()
|
|
567
576
|
} catch (error) {
|
|
568
577
|
if (signal?.aborted) throw aborted(signal)
|
|
569
|
-
return
|
|
578
|
+
return timeout.signal.aborted ? { reason: `timed out after ${seconds(budget)}`, timedOut: true } : { reason: `request failed: ${reasonOf(error)}` }
|
|
579
|
+
} finally {
|
|
580
|
+
clearTimeout(timer)
|
|
570
581
|
}
|
|
571
582
|
if (!response.ok) {
|
|
572
583
|
const detail = errorDetail(text)
|
|
@@ -593,7 +604,7 @@ export function createAutoSearchProvider(deps, config = {}) {
|
|
|
593
604
|
const keyOf = candidate => [route.provider, candidate.url, candidate.model ?? ''].join('\n')
|
|
594
605
|
const works = candidate => memory.get(keyOf(candidate))?.works === true
|
|
595
606
|
const ordered = [...plan.candidates.filter(works), ...plan.candidates.filter(candidate => !works(candidate))]
|
|
596
|
-
|
|
607
|
+
let deadline = now() + options.nativeTimeoutMs
|
|
597
608
|
const notes = []
|
|
598
609
|
let credential
|
|
599
610
|
for (const candidate of ordered) {
|
|
@@ -622,6 +633,9 @@ export function createAutoSearchProvider(deps, config = {}) {
|
|
|
622
633
|
notes.push(`${where}: ${outcome.reason}`)
|
|
623
634
|
if (outcome.skip !== undefined) memory.set(keyOf(candidate), { until: now() + SKIP_FOR[outcome.skip], reason: outcome.reason })
|
|
624
635
|
if (outcome.final) break
|
|
636
|
+
// A timed-out attempt had all the time that was left, but the timer and now() use
|
|
637
|
+
// different clocks, so now() can still read a millisecond before the deadline.
|
|
638
|
+
if (outcome.timedOut) deadline = now()
|
|
625
639
|
}
|
|
626
640
|
return { reason: `route "${route.provider}": ${notes.join('; ')}` }
|
|
627
641
|
}
|
|
@@ -651,14 +665,16 @@ export function createAutoSearchProvider(deps, config = {}) {
|
|
|
651
665
|
const remaining = deadline - now()
|
|
652
666
|
if (remaining <= 0) { attempts.push(`${host}: skipped, out of time`); continue }
|
|
653
667
|
// Leave later engines a share of the budget when an earlier one hangs.
|
|
654
|
-
const timeout =
|
|
668
|
+
const { timeout, timer } = timeoutAfter(index === options.engines.length - 1 ? remaining : Math.min(remaining, Math.ceil(options.fallbackTimeoutMs / 2)))
|
|
655
669
|
let fetched
|
|
656
670
|
try {
|
|
657
|
-
fetched = await fetchPage(engine, url, signal === undefined ? timeout : AbortSignal.any([signal, timeout]))
|
|
671
|
+
fetched = await fetchPage(engine, url, signal === undefined ? timeout.signal : AbortSignal.any([signal, timeout.signal]))
|
|
658
672
|
} catch (error) {
|
|
659
673
|
if (signal?.aborted) throw aborted(signal)
|
|
660
|
-
attempts.push(`${host}: ${timeout.aborted ? 'timed out' : `${error?.code ? `${error.code} ` : ''}${reasonOf(error)}`}`)
|
|
674
|
+
attempts.push(`${host}: ${timeout.signal.aborted ? 'timed out' : `${error?.code ? `${error.code} ` : ''}${reasonOf(error)}`}`)
|
|
661
675
|
continue
|
|
676
|
+
} finally {
|
|
677
|
+
clearTimeout(timer)
|
|
662
678
|
}
|
|
663
679
|
const at = new URL(fetched.url).host
|
|
664
680
|
if (fetched.page.statusCode !== 200) { attempts.push(`${at}: HTTP ${fetched.page.statusCode}`); continue }
|