@lemoncat7/dsh-partner 1.1.6 → 1.3.9
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 +14 -5
- package/lib/agent-runtime.d.ts +42 -3
- package/lib/agent-runtime.d.ts.map +1 -1
- package/lib/agent-runtime.js +523 -40
- package/lib/agent-runtime.js.map +1 -1
- package/lib/api.d.ts +2 -0
- package/lib/api.d.ts.map +1 -1
- package/lib/api.js +29 -2
- package/lib/api.js.map +1 -1
- package/lib/autonomy.d.ts +2 -7
- package/lib/autonomy.d.ts.map +1 -1
- package/lib/autonomy.js +40 -28
- package/lib/autonomy.js.map +1 -1
- package/lib/channels/manager.d.ts +1 -0
- package/lib/channels/manager.d.ts.map +1 -1
- package/lib/channels/manager.js +11 -3
- package/lib/channels/manager.js.map +1 -1
- package/lib/client-api.d.ts +43 -0
- package/lib/client-api.d.ts.map +1 -1
- package/lib/client-api.js.map +1 -1
- package/lib/client.d.ts.map +1 -1
- package/lib/client.js +461 -20
- package/lib/client.js.map +3 -3
- package/lib/concern-domain.d.ts +121 -0
- package/lib/concern-domain.d.ts.map +1 -0
- package/lib/concern-domain.js +182 -0
- package/lib/concern-domain.js.map +1 -0
- package/lib/concern-notification.d.ts +6 -0
- package/lib/concern-notification.d.ts.map +1 -0
- package/lib/concern-notification.js +27 -0
- package/lib/concern-notification.js.map +1 -0
- package/lib/concern-sources.d.ts +10 -0
- package/lib/concern-sources.d.ts.map +1 -0
- package/lib/concern-sources.js +81 -0
- package/lib/concern-sources.js.map +1 -0
- package/lib/concern-store.d.ts +41 -0
- package/lib/concern-store.d.ts.map +1 -0
- package/lib/concern-store.js +527 -0
- package/lib/concern-store.js.map +1 -0
- package/lib/daily-review.d.ts +3 -1
- package/lib/daily-review.d.ts.map +1 -1
- package/lib/daily-review.js +8 -2
- package/lib/daily-review.js.map +1 -1
- package/lib/domain.d.ts +4 -1
- package/lib/domain.d.ts.map +1 -1
- package/lib/domain.js +23 -0
- package/lib/domain.js.map +1 -1
- package/lib/heartbeat.d.ts +10 -2
- package/lib/heartbeat.d.ts.map +1 -1
- package/lib/heartbeat.js +81 -17
- package/lib/heartbeat.js.map +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +33 -5
- package/lib/index.js.map +1 -1
- package/lib/memory-domain.d.ts +3 -0
- package/lib/memory-domain.d.ts.map +1 -1
- package/lib/memory-reflection.d.ts +8 -3
- package/lib/memory-reflection.d.ts.map +1 -1
- package/lib/memory-reflection.js +66 -13
- package/lib/memory-reflection.js.map +1 -1
- package/lib/memory-store.d.ts +8 -0
- package/lib/memory-store.d.ts.map +1 -1
- package/lib/memory-store.js +27 -0
- package/lib/memory-store.js.map +1 -1
- package/lib/store.d.ts.map +1 -1
- package/lib/store.js +39 -3
- package/lib/store.js.map +1 -1
- package/lib/time-format.d.ts +2 -0
- package/lib/time-format.d.ts.map +1 -0
- package/lib/time-format.js +20 -0
- package/lib/time-format.js.map +1 -0
- package/package.json +3 -1
package/lib/agent-runtime.js
CHANGED
|
@@ -1,27 +1,55 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { mkdir, realpath, stat, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { basename, extname, join, relative, resolve } from 'node:path';
|
|
4
|
-
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
5
|
-
import {
|
|
3
|
+
import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { BlockAssembler, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
5
|
+
import { createScope, scopeOf } from '@deepseek-ai/dsh-scope';
|
|
6
|
+
import { concernObservationPrompt } from './autonomy.js';
|
|
7
|
+
import { boundedConcernCheckMinutes } from './concern-domain.js';
|
|
6
8
|
import { PARTNER_MEDIA_MAX_BYTES, safeMediaName } from './channel-message.js';
|
|
7
|
-
|
|
9
|
+
import { listConcernFileSources } from './concern-sources.js';
|
|
10
|
+
import { CONCERN_CREATED_NOTICE, renderConcernCreatedNotice } from './concern-notification.js';
|
|
11
|
+
const HEARTBEAT_TIMEOUT_MS = 115_000;
|
|
12
|
+
const HEARTBEAT_DISCOVERY_BUDGET_MS = 72_000;
|
|
13
|
+
const HEARTBEAT_TOOL_TIMEOUT_MS = 18_000;
|
|
14
|
+
const HEARTBEAT_WEB_TOOL_TIMEOUT_MS = 30_000;
|
|
15
|
+
const HEARTBEAT_TOOL_RESULT_LIMIT = 5_000;
|
|
16
|
+
const HEARTBEAT_KNOWLEDGE_RESULT_LIMIT = 8_000;
|
|
17
|
+
const HEARTBEAT_WEB_FETCH_RESULT_LIMIT = 40_000;
|
|
18
|
+
const HEARTBEAT_WEB_SOURCE_RESULT_LIMIT = 125_000;
|
|
19
|
+
const HEARTBEAT_AUDIT_INPUT_LIMIT = 320;
|
|
20
|
+
const HEARTBEAT_AUDIT_OUTPUT_LIMIT = 520;
|
|
21
|
+
const HEARTBEAT_READ_ONLY_TOOLS = new Set([
|
|
22
|
+
'knowledge_base_search',
|
|
23
|
+
'knowledge_search',
|
|
24
|
+
'knowledge_read',
|
|
25
|
+
'web_search',
|
|
26
|
+
'web_fetch',
|
|
27
|
+
'web_source',
|
|
28
|
+
'glob',
|
|
29
|
+
'grep',
|
|
30
|
+
'read',
|
|
31
|
+
]);
|
|
32
|
+
const HEARTBEAT_FILE_UPDATE_TOOLS = new Set(['write', 'edit', 'str_replace_editor']);
|
|
33
|
+
const HEARTBEAT_TOOLS = new Set([...HEARTBEAT_READ_ONLY_TOOLS, ...HEARTBEAT_FILE_UPDATE_TOOLS]);
|
|
8
34
|
export class PartnerAgentRuntime {
|
|
9
35
|
ctx;
|
|
10
36
|
store;
|
|
11
37
|
defaultCwd;
|
|
12
38
|
memory;
|
|
13
39
|
reflection;
|
|
40
|
+
concerns;
|
|
14
41
|
handles = new Map();
|
|
15
42
|
queues = new Map();
|
|
16
43
|
heartbeatQueues = new Map();
|
|
17
44
|
steeringQueues = new Map();
|
|
18
45
|
workspaces = new Map();
|
|
19
|
-
constructor(ctx, store, defaultCwd, memory, reflection) {
|
|
46
|
+
constructor(ctx, store, defaultCwd, memory, reflection, concerns) {
|
|
20
47
|
this.ctx = ctx;
|
|
21
48
|
this.store = store;
|
|
22
49
|
this.defaultCwd = defaultCwd;
|
|
23
50
|
this.memory = memory;
|
|
24
51
|
this.reflection = reflection;
|
|
52
|
+
this.concerns = concerns;
|
|
25
53
|
}
|
|
26
54
|
async reply(companion, channelId, userId, message) {
|
|
27
55
|
const key = `${channelId}:${userId}`;
|
|
@@ -67,6 +95,14 @@ export class PartnerAgentRuntime {
|
|
|
67
95
|
content: [{ type: 'text', text: renderMemory(recalled) }],
|
|
68
96
|
source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: '伙伴为插话召回了相关长期记忆' },
|
|
69
97
|
}));
|
|
98
|
+
const deferred = await this.concerns?.deferred(companion.id, memoryScope(channelId, userId), inbound.query, 2).catch(() => []);
|
|
99
|
+
if (deferred && deferred.length > 0) {
|
|
100
|
+
agent.inject(createUserMessage({
|
|
101
|
+
content: [{ type: 'text', text: renderDeferredMentions(deferred) }],
|
|
102
|
+
source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: '伙伴想起了与当前消息相关的挂念' },
|
|
103
|
+
}));
|
|
104
|
+
await this.concerns?.markMentioned(companion.id, deferred.map(item => item.id)).catch(() => { });
|
|
105
|
+
}
|
|
70
106
|
agent.steer(createUserMessage({ content: inbound.content, source: { kind: 'user' } }));
|
|
71
107
|
return true;
|
|
72
108
|
}
|
|
@@ -95,12 +131,12 @@ export class PartnerAgentRuntime {
|
|
|
95
131
|
});
|
|
96
132
|
return next;
|
|
97
133
|
}
|
|
98
|
-
async heartbeat(companion, route) {
|
|
134
|
+
async heartbeat(companion, route, concerns) {
|
|
99
135
|
const key = `${route.channelId}:${route.userId}`;
|
|
100
136
|
const queued = this.heartbeatQueues.get(key);
|
|
101
137
|
if (queued !== undefined)
|
|
102
138
|
return queued;
|
|
103
|
-
const current = this.runHeartbeatWhenIdle(key, companion, route);
|
|
139
|
+
const current = this.runHeartbeatWhenIdle(key, companion, route, concerns);
|
|
104
140
|
this.heartbeatQueues.set(key, current);
|
|
105
141
|
try {
|
|
106
142
|
return await current;
|
|
@@ -110,27 +146,91 @@ export class PartnerAgentRuntime {
|
|
|
110
146
|
this.heartbeatQueues.delete(key);
|
|
111
147
|
}
|
|
112
148
|
}
|
|
113
|
-
async
|
|
149
|
+
async persistKnowledgeObservations(companion, route, concerns, observations) {
|
|
150
|
+
const service = this.ctx.get('dshKnowledgeTracking');
|
|
151
|
+
if (service === undefined || observations.length === 0)
|
|
152
|
+
return;
|
|
153
|
+
const agent = await this.ensureAgent(companion, route);
|
|
154
|
+
const byId = new Map(concerns.map(item => [item.id, item]));
|
|
155
|
+
const controller = new AbortController();
|
|
156
|
+
const timeout = setTimeout(() => controller.abort(new Error('知识库观察记录超时')), 30_000);
|
|
157
|
+
timeout.unref?.();
|
|
158
|
+
try {
|
|
159
|
+
for (const observation of observations) {
|
|
160
|
+
if (observation.decision === 'drop')
|
|
161
|
+
continue;
|
|
162
|
+
const concern = byId.get(observation.concernId);
|
|
163
|
+
if (concern === undefined)
|
|
164
|
+
continue;
|
|
165
|
+
const references = concern.resources.filter(item => item.kind === 'knowledge').map(item => item.locator).slice(0, 4);
|
|
166
|
+
if (references.length === 0 && concern.watchKind !== 'knowledge')
|
|
167
|
+
continue;
|
|
168
|
+
for (const reference of references.length > 0 ? references : [undefined]) {
|
|
169
|
+
await service.record(agent, {
|
|
170
|
+
id: observation.id,
|
|
171
|
+
subject: concern.subject,
|
|
172
|
+
event: observation.event,
|
|
173
|
+
evidence: observation.evidence,
|
|
174
|
+
source: observation.source,
|
|
175
|
+
...(reference === undefined ? {} : { reference }),
|
|
176
|
+
at: observation.createdAt,
|
|
177
|
+
}, controller.signal).catch(error => {
|
|
178
|
+
this.ctx.logger.warn(`dsh-partner: knowledge observation fell back to local storage: ${errorMessage(error)}`);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
clearTimeout(timeout);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async concernSources(companion, query = '') {
|
|
188
|
+
const routes = this.store.snapshot().sessions
|
|
189
|
+
.filter(item => item.companionId === companion.id)
|
|
190
|
+
.sort((left, right) => right.lastMessageAt - left.lastMessageAt);
|
|
191
|
+
const root = routes[0]?.cwd ?? partnerCwd(this.defaultCwd, companion.id);
|
|
192
|
+
const files = await listConcernFileSources(root, query, 24);
|
|
193
|
+
const service = this.ctx.get('dshKnowledgeTracking');
|
|
194
|
+
if (service?.list === undefined || routes[0] === undefined)
|
|
195
|
+
return files;
|
|
196
|
+
const controller = new AbortController();
|
|
197
|
+
const timeout = setTimeout(() => controller.abort(new Error('知识文档候选查询超时')), 8_000);
|
|
198
|
+
timeout.unref?.();
|
|
199
|
+
try {
|
|
200
|
+
const route = routes[0];
|
|
201
|
+
const knowledge = await service.list({ session: { id: route.sessionId, header: { cwd: root }, events: [] } }, query, 16, controller.signal).catch(error => {
|
|
202
|
+
this.ctx.logger.warn(`dsh-partner: knowledge concern sources unavailable: ${errorMessage(error)}`);
|
|
203
|
+
return [];
|
|
204
|
+
});
|
|
205
|
+
return [...files, ...knowledge].slice(0, 40);
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
clearTimeout(timeout);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async runHeartbeatWhenIdle(key, companion, route, concerns) {
|
|
114
212
|
for (;;) {
|
|
115
213
|
await this.queues.get(key)?.catch(() => { });
|
|
116
|
-
const
|
|
117
|
-
if (
|
|
118
|
-
await
|
|
119
|
-
const stableSeq =
|
|
214
|
+
const conversation = await this.ensureAgent(companion, route);
|
|
215
|
+
if (conversation.status !== 'idle')
|
|
216
|
+
await conversation.whenIdle();
|
|
217
|
+
const stableSeq = conversation.session.seq;
|
|
120
218
|
await delay(2_000);
|
|
121
|
-
if (this.queues.has(key)
|
|
219
|
+
if (this.queues.has(key))
|
|
220
|
+
continue;
|
|
221
|
+
if (conversation.status !== 'idle' || conversation.session.seq !== stableSeq)
|
|
122
222
|
continue;
|
|
123
|
-
return this.
|
|
223
|
+
return this.driveScopedHeartbeat(conversation, companion, concerns);
|
|
124
224
|
}
|
|
125
225
|
}
|
|
126
226
|
async observeSessionEvent(session, event) {
|
|
127
|
-
if (event.type !== 'turn/end' || event.data.reason.kind !== 'completed'
|
|
227
|
+
if (event.type !== 'turn/end' || event.data.reason.kind !== 'completed')
|
|
128
228
|
return;
|
|
129
229
|
const route = this.store.snapshot().sessions.find(item => item.sessionId === session.id);
|
|
130
230
|
if (route === undefined)
|
|
131
231
|
return;
|
|
132
232
|
const companion = this.store.snapshot().companions.find(item => item.id === route.companionId);
|
|
133
|
-
if (companion === undefined
|
|
233
|
+
if (companion === undefined)
|
|
134
234
|
return;
|
|
135
235
|
const events = completedTurnEvents(session.events, event);
|
|
136
236
|
const prompt = events.find(item => item.type === 'user/message' && item.data.source.kind === 'user');
|
|
@@ -139,42 +239,89 @@ export class PartnerAgentRuntime {
|
|
|
139
239
|
const assistantText = replies.flatMap(item => item.type === 'assistant/message' ? [textContent(item.data.message.content)] : []).filter(Boolean).join('\n\n');
|
|
140
240
|
if (!userText || !assistantText)
|
|
141
241
|
return;
|
|
142
|
-
|
|
143
|
-
|
|
242
|
+
const scopeId = memoryScope(route.channelId, route.userId);
|
|
243
|
+
const concernDirective = await this.concerns?.applyUserDirective(companion.id, scopeId, userText, event.time);
|
|
244
|
+
if (!companion.automation.memory.enabled || this.reflection === undefined)
|
|
245
|
+
return;
|
|
246
|
+
const created = await this.reflection.reflect(companion, {
|
|
247
|
+
id: `turn-${randomUUID()}`, companionId: companion.id, scopeId,
|
|
144
248
|
sessionId: session.id, at: event.time, user: userText, assistant: assistantText,
|
|
249
|
+
...(concernDirective === undefined ? {} : { concernDirective }),
|
|
145
250
|
});
|
|
251
|
+
if (created.length > 0)
|
|
252
|
+
await this.recordConcernCreatedNotice(companion, scopeId, created).catch(error => {
|
|
253
|
+
this.ctx.logger.warn(`dsh-partner: automatic concern creation notice failed: ${errorMessage(error)}`);
|
|
254
|
+
});
|
|
146
255
|
await this.store.update(state => {
|
|
147
256
|
const target = state.sessions.find(item => item.id === route.id);
|
|
148
257
|
if (target)
|
|
149
258
|
target.lastMessageAt = Date.now();
|
|
150
259
|
});
|
|
151
260
|
}
|
|
152
|
-
async
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
261
|
+
async recordConcernCreatedNotice(companion, scopeId, concerns) {
|
|
262
|
+
if (concerns.length === 0)
|
|
263
|
+
return false;
|
|
264
|
+
const route = this.store.snapshot().sessions
|
|
265
|
+
.filter(item => item.companionId === companion.id && memoryScope(item.channelId, item.userId) === scopeId)
|
|
266
|
+
.sort((left, right) => right.lastMessageAt - left.lastMessageAt)[0];
|
|
267
|
+
if (route === undefined)
|
|
268
|
+
return false;
|
|
269
|
+
const conversation = await this.ensureAgent(companion, route);
|
|
270
|
+
if (conversation.status !== 'idle')
|
|
271
|
+
await conversation.whenIdle();
|
|
272
|
+
conversation.session.append('user/message', createUserMessage({
|
|
273
|
+
content: [{ type: 'text', text: renderConcernCreatedNotice(concerns) }],
|
|
274
|
+
source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: CONCERN_CREATED_NOTICE },
|
|
275
|
+
}), { surfaceOp: 'append' });
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
async driveScopedHeartbeat(conversation, companion, concerns) {
|
|
279
|
+
const scope = await createHeartbeatScope(this.ctx, conversation, concerns);
|
|
280
|
+
try {
|
|
281
|
+
return await this.driveEphemeralHeartbeat(scope.agent, companion, concerns);
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
await scope.dispose();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
async driveEphemeralHeartbeat(agent, companion, concerns) {
|
|
288
|
+
const controller = new AbortController();
|
|
289
|
+
const timeout = setTimeout(() => controller.abort(new Error('伙伴心跳超过安全时限')), HEARTBEAT_TIMEOUT_MS);
|
|
168
290
|
timeout.unref?.();
|
|
169
291
|
try {
|
|
170
|
-
await agent.
|
|
292
|
+
const execution = await runHeartbeatInference(this.ctx, agent, companion, concerns, controller.signal);
|
|
293
|
+
return controller.signal.aborted
|
|
294
|
+
? { ...execution, error: '伙伴心跳超过安全时限,已收束并终止' }
|
|
295
|
+
: execution;
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
const now = Date.now();
|
|
299
|
+
return {
|
|
300
|
+
concerns,
|
|
301
|
+
candidates: [],
|
|
302
|
+
startedAt: now,
|
|
303
|
+
completedAt: now,
|
|
304
|
+
tools: [],
|
|
305
|
+
error: controller.signal.aborted ? '伙伴心跳超过安全时限,已收束并终止' : errorMessage(error),
|
|
306
|
+
};
|
|
171
307
|
}
|
|
172
308
|
finally {
|
|
173
309
|
clearTimeout(timeout);
|
|
174
310
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
311
|
+
}
|
|
312
|
+
async recordHeartbeatActivity(companion, route, execution, outcome, deliveryError) {
|
|
313
|
+
const conversation = await this.ensureAgent(companion, route);
|
|
314
|
+
if (conversation.status !== 'idle')
|
|
315
|
+
await conversation.whenIdle();
|
|
316
|
+
conversation.session.append('user/message', createUserMessage({
|
|
317
|
+
content: [{ type: 'text', text: renderHeartbeatActivity(execution, outcome, deliveryError) }],
|
|
318
|
+
source: {
|
|
319
|
+
kind: 'plugin',
|
|
320
|
+
plugin: '@lemoncat7/dsh-partner',
|
|
321
|
+
form: 'notice',
|
|
322
|
+
summary: '伙伴正在进行低打扰心跳检查',
|
|
323
|
+
},
|
|
324
|
+
}), { surfaceOp: 'append' });
|
|
178
325
|
}
|
|
179
326
|
async resetCompanion(companionId) {
|
|
180
327
|
await this.releaseCompanion(companionId);
|
|
@@ -218,6 +365,12 @@ export class PartnerAgentRuntime {
|
|
|
218
365
|
content: [{ type: 'text', text: renderMemory(recalled) }],
|
|
219
366
|
source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: '伙伴回忆了与当前消息相关的长期记忆' },
|
|
220
367
|
}));
|
|
368
|
+
const deferred = await this.concerns?.deferred(companion.id, memoryScope(channelId, userId), inbound.query, 2).catch(() => []);
|
|
369
|
+
if (deferred && deferred.length > 0)
|
|
370
|
+
agent.inject(createUserMessage({
|
|
371
|
+
content: [{ type: 'text', text: renderDeferredMentions(deferred) }],
|
|
372
|
+
source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: '伙伴想起了与当前消息相关的挂念' },
|
|
373
|
+
}));
|
|
221
374
|
const startSeq = agent.session.seq;
|
|
222
375
|
agent.followup(createUserMessage({
|
|
223
376
|
content: inbound.content,
|
|
@@ -232,6 +385,8 @@ export class PartnerAgentRuntime {
|
|
|
232
385
|
});
|
|
233
386
|
if (!response)
|
|
234
387
|
throw new Error('伙伴没有生成可发送的文本回复');
|
|
388
|
+
if (deferred && deferred.length > 0)
|
|
389
|
+
await this.concerns?.markMentioned(companion.id, deferred.map(item => item.id)).catch(() => { });
|
|
235
390
|
return { text: response, attachments: await extractOutboundAttachments(response, session.cwd ?? this.defaultCwd) };
|
|
236
391
|
}
|
|
237
392
|
async persistInbound(session, message) {
|
|
@@ -431,6 +586,12 @@ function renderMemory(entries) {
|
|
|
431
586
|
...entries.map(entry => `- [${entry.kind}|置信度 ${entry.confidence.toFixed(2)}] ${entry.subject}:${entry.content}`),
|
|
432
587
|
].join('\n');
|
|
433
588
|
}
|
|
589
|
+
function renderDeferredMentions(entries) {
|
|
590
|
+
return [
|
|
591
|
+
'伙伴之前发现了与用户当前消息相关的新变化。若自然且确实有帮助,可以在本次回答末尾顺手提一句;不要抢占当前问题,也不要声称刚刚搜索。',
|
|
592
|
+
...entries.map(item => `- ${item.event}${item.evidence ? `(依据:${item.evidence})` : ''}`),
|
|
593
|
+
].join('\n');
|
|
594
|
+
}
|
|
434
595
|
export function resolveAgentOptions(defaultModel, companion) {
|
|
435
596
|
return {
|
|
436
597
|
...defaultModel.currentSelection(),
|
|
@@ -452,7 +613,327 @@ export function renewedSession(previous, now = Date.now()) {
|
|
|
452
613
|
export function partnerCwd(root, companionId) {
|
|
453
614
|
return join(root, 'partners', companionId);
|
|
454
615
|
}
|
|
455
|
-
function
|
|
616
|
+
async function createHeartbeatScope(ctx, conversation, concerns) {
|
|
617
|
+
const parent = scopeOf(conversation.ctx);
|
|
618
|
+
if (parent === undefined)
|
|
619
|
+
throw new Error('伙伴心跳无法解析当前 Agent 作用域');
|
|
620
|
+
const availableTools = [...HEARTBEAT_TOOLS].filter(name => ctx.tools.get(name, conversation) !== undefined);
|
|
621
|
+
const policy = heartbeatToolPolicy(concerns, availableTools);
|
|
622
|
+
const allowedTools = [...policy.allowed];
|
|
623
|
+
if (allowedTools.length === 0)
|
|
624
|
+
throw new Error('伙伴心跳没有可用的只读发现工具');
|
|
625
|
+
const cwd = conversation.session.header.cwd;
|
|
626
|
+
if (!cwd)
|
|
627
|
+
throw new Error('伙伴心跳缺少当前伙伴工作目录');
|
|
628
|
+
const fileAccess = await resolveHeartbeatFileAccess(cwd, concerns);
|
|
629
|
+
const agent = {};
|
|
630
|
+
const scope = createScope(ctx, agent, { parent });
|
|
631
|
+
const agentCtx = scope.ctx.extend({ agent });
|
|
632
|
+
Object.assign(agent, {
|
|
633
|
+
id: conversation.id,
|
|
634
|
+
options: conversation.options,
|
|
635
|
+
session: conversation.session,
|
|
636
|
+
inbox: conversation.inbox,
|
|
637
|
+
status: 'idle',
|
|
638
|
+
ctx: agentCtx,
|
|
639
|
+
cancel: () => { },
|
|
640
|
+
whenIdle: async () => { },
|
|
641
|
+
runMaintenance: async (job) => job(new AbortController().signal),
|
|
642
|
+
send: () => { throw new Error('心跳作用域不接受会话消息'); },
|
|
643
|
+
followup: () => { throw new Error('心跳作用域不接受会话消息'); },
|
|
644
|
+
steer: () => { throw new Error('心跳作用域不接受会话消息'); },
|
|
645
|
+
inject: () => { throw new Error('心跳作用域不接受会话消息'); },
|
|
646
|
+
});
|
|
647
|
+
agentCtx.tools.presentAs('native');
|
|
648
|
+
agentCtx.tools.restrict({ allow: allowedTools });
|
|
649
|
+
agentCtx.tools.guard(exec => {
|
|
650
|
+
if (!policy.allowed.has(exec.name))
|
|
651
|
+
return `当前挂念的来源策略不允许调用 ${exec.name}`;
|
|
652
|
+
return heartbeatToolDenial(exec.name, exec.arguments, fileAccess);
|
|
653
|
+
});
|
|
654
|
+
return { agent, dispose: () => scope.dispose() };
|
|
655
|
+
}
|
|
656
|
+
export function heartbeatToolPolicy(concerns, available) {
|
|
657
|
+
const installed = new Set(available);
|
|
658
|
+
const hasLinkedFile = concerns.some(item => item.resources?.some(resource => resource.kind === 'file') === true);
|
|
659
|
+
return {
|
|
660
|
+
allowed: new Set([...installed].filter(name => HEARTBEAT_READ_ONLY_TOOLS.has(name) || (hasLinkedFile && HEARTBEAT_FILE_UPDATE_TOOLS.has(name)))),
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
async function runHeartbeatInference(ctx, agent, companion, concerns, signal) {
|
|
664
|
+
const startedAt = Date.now();
|
|
665
|
+
const traces = [];
|
|
666
|
+
const discoveryDeadline = startedAt + HEARTBEAT_DISCOVERY_BUDGET_MS;
|
|
667
|
+
try {
|
|
668
|
+
const selection = resolveAgentOptions(ctx.agentDefaultModel, companion);
|
|
669
|
+
if (!selection.provider || !selection.model)
|
|
670
|
+
throw new Error('伙伴心跳没有可用的模型路由');
|
|
671
|
+
const tools = ctx.tools.schemas(agent).filter(tool => HEARTBEAT_TOOLS.has(tool.name));
|
|
672
|
+
if (tools.length === 0)
|
|
673
|
+
throw new Error('伙伴心跳没有可用的只读发现工具');
|
|
674
|
+
const messages = [createUserMessage({
|
|
675
|
+
content: [{ type: 'text', text: concernObservationPrompt(concerns, agent.session.header.cwd) }],
|
|
676
|
+
source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: '伙伴观察挂念变化' },
|
|
677
|
+
})];
|
|
678
|
+
for (;;) {
|
|
679
|
+
const discoveryOpen = Date.now() < discoveryDeadline;
|
|
680
|
+
const prepared = await ctx.llm.prepareCall(selection, signal);
|
|
681
|
+
const assembler = new BlockAssembler();
|
|
682
|
+
for await (const chunk of prepared.stream({
|
|
683
|
+
...prepared.config,
|
|
684
|
+
messages,
|
|
685
|
+
system: renderPersona(companion, 'heartbeat'),
|
|
686
|
+
tools: discoveryOpen ? tools : [],
|
|
687
|
+
signal,
|
|
688
|
+
}))
|
|
689
|
+
assembler.push(chunk);
|
|
690
|
+
const finish = assembler.finish;
|
|
691
|
+
if (finish.kind === 'error' || finish.kind === 'aborted') {
|
|
692
|
+
throw new Error(`伙伴心跳模型调用失败:${finish.failure.message}`);
|
|
693
|
+
}
|
|
694
|
+
const response = assembler.message({ kind: 'model', provider: prepared.config.provider, model: prepared.config.model });
|
|
695
|
+
messages.push(response);
|
|
696
|
+
const pending = response.content.filter((block) => block.type === 'tool-call');
|
|
697
|
+
if (pending.length === 0) {
|
|
698
|
+
const output = textContent(response.content);
|
|
699
|
+
return {
|
|
700
|
+
concerns,
|
|
701
|
+
candidates: parseConcernObservations(output, new Set(concerns.map(item => item.id))),
|
|
702
|
+
startedAt,
|
|
703
|
+
completedAt: Date.now(),
|
|
704
|
+
output,
|
|
705
|
+
tools: traces,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
for (const call of pending) {
|
|
709
|
+
const argumentsValue = boundHeartbeatToolArguments(call.name, parseHeartbeatToolArguments(call));
|
|
710
|
+
const toolStartedAt = Date.now();
|
|
711
|
+
const remainingDiscoveryMs = discoveryDeadline - toolStartedAt;
|
|
712
|
+
const result = remainingDiscoveryMs <= 0
|
|
713
|
+
? { isError: true, content: [{ type: 'text', text: '本轮发现阶段的时间预算已用完,请根据已有结果立即输出最终 Observation JSON。' }] }
|
|
714
|
+
: await executeHeartbeatTool(ctx, agent, call, argumentsValue, signal, remainingDiscoveryMs);
|
|
715
|
+
const toolCompletedAt = Date.now();
|
|
716
|
+
const visibleContent = heartbeatToolContent(call.name, result.content);
|
|
717
|
+
traces.push({
|
|
718
|
+
name: call.name,
|
|
719
|
+
input: auditValue(argumentsValue, HEARTBEAT_AUDIT_INPUT_LIMIT),
|
|
720
|
+
output: auditText(textContent(visibleContent) || '工具返回了非文本结果', HEARTBEAT_AUDIT_OUTPUT_LIMIT),
|
|
721
|
+
startedAt: toolStartedAt,
|
|
722
|
+
completedAt: toolCompletedAt,
|
|
723
|
+
status: result.isError ? 'failed' : 'completed',
|
|
724
|
+
});
|
|
725
|
+
messages.push(createToolResultMessage({ callId: call.id, content: visibleContent, isError: result.isError }));
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
return { concerns, candidates: [], startedAt, completedAt: Date.now(), tools: traces, error: errorMessage(error) };
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
export function parseConcernObservations(raw, allowedIds) {
|
|
734
|
+
const match = raw.trim().match(/\{[\s\S]*\}/);
|
|
735
|
+
if (!match)
|
|
736
|
+
throw new Error('伙伴心跳没有返回 Observation JSON');
|
|
737
|
+
const parsed = JSON.parse(match[0]);
|
|
738
|
+
if (!Array.isArray(parsed.observations))
|
|
739
|
+
throw new Error('伙伴心跳 Observation 格式无效');
|
|
740
|
+
const seen = new Set();
|
|
741
|
+
return parsed.observations.flatMap(value => {
|
|
742
|
+
if (!isRecord(value))
|
|
743
|
+
return [];
|
|
744
|
+
const concernId = typeof value.concernId === 'string' ? value.concernId : '';
|
|
745
|
+
if (!allowedIds.has(concernId) || seen.has(concernId))
|
|
746
|
+
return [];
|
|
747
|
+
seen.add(concernId);
|
|
748
|
+
const changed = value.changed === true;
|
|
749
|
+
const nextCheckInMinutes = boundedConcernCheckMinutes(value.nextCheckInMinutes);
|
|
750
|
+
const notificationRuleEffect = value.notificationRuleEffect === 'notify' || value.notificationRuleEffect === 'suppress'
|
|
751
|
+
? value.notificationRuleEffect
|
|
752
|
+
: 'auto';
|
|
753
|
+
return [{
|
|
754
|
+
concernId,
|
|
755
|
+
changed,
|
|
756
|
+
event: changed && typeof value.event === 'string' ? value.event.trim().slice(0, 800) : '',
|
|
757
|
+
evidence: typeof value.evidence === 'string' ? value.evidence.trim().slice(0, 2_000) : '',
|
|
758
|
+
source: typeof value.source === 'string' ? value.source.trim().slice(0, 240) : '',
|
|
759
|
+
relevance: boundedScore(value.relevance),
|
|
760
|
+
confidence: boundedScore(value.confidence),
|
|
761
|
+
actionability: boundedScore(value.actionability),
|
|
762
|
+
...(nextCheckInMinutes === undefined ? {} : { nextCheckInMinutes }),
|
|
763
|
+
notificationRuleEffect,
|
|
764
|
+
notificationRuleReason: typeof value.notificationRuleReason === 'string' ? value.notificationRuleReason.trim().slice(0, 800) : '',
|
|
765
|
+
}];
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
function boundedScore(value) {
|
|
769
|
+
return typeof value === 'number' && Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : .5;
|
|
770
|
+
}
|
|
771
|
+
function parseHeartbeatToolArguments(call) {
|
|
772
|
+
try {
|
|
773
|
+
return JSON.parse(call.arguments);
|
|
774
|
+
}
|
|
775
|
+
catch {
|
|
776
|
+
return { __invalidArguments: call.arguments };
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
async function resolveHeartbeatFileAccess(cwd, concerns) {
|
|
780
|
+
const root = await realpath(cwd);
|
|
781
|
+
const writable = new Set();
|
|
782
|
+
const locators = concerns.flatMap(item => item.resources ?? []).filter(item => item.kind === 'file').map(item => item.locator);
|
|
783
|
+
for (const locator of new Set(locators)) {
|
|
784
|
+
const lexical = resolve(root, locator);
|
|
785
|
+
if (!pathInside(root, lexical) || isPartnerMemoryPath(relative(root, lexical)))
|
|
786
|
+
continue;
|
|
787
|
+
try {
|
|
788
|
+
const actual = await realpath(lexical);
|
|
789
|
+
const info = await stat(actual);
|
|
790
|
+
if (!info.isFile() || !pathInside(root, actual) || isPartnerMemoryPath(relative(root, actual)))
|
|
791
|
+
continue;
|
|
792
|
+
writable.add(lexical);
|
|
793
|
+
writable.add(actual);
|
|
794
|
+
}
|
|
795
|
+
catch { /* References may become stale; stale files remain read-only and cannot be recreated. */ }
|
|
796
|
+
}
|
|
797
|
+
return { root, writable };
|
|
798
|
+
}
|
|
799
|
+
export function heartbeatToolDenial(name, argumentsValue, access) {
|
|
800
|
+
if (!isRecord(argumentsValue))
|
|
801
|
+
return undefined;
|
|
802
|
+
const isRead = ['glob', 'grep', 'read'].includes(name);
|
|
803
|
+
const isUpdate = HEARTBEAT_FILE_UPDATE_TOOLS.has(name);
|
|
804
|
+
if (!isRead && !isUpdate)
|
|
805
|
+
return undefined;
|
|
806
|
+
const paths = [argumentsValue.path, argumentsValue.file_path].filter((value) => typeof value === 'string');
|
|
807
|
+
if (paths.some(isPartnerMemoryPath))
|
|
808
|
+
return '伙伴心跳不能访问长期记忆、挂念数据库、会话归档或其备份目录;请改查普通工作文件或其他安全来源。';
|
|
809
|
+
if (access && paths.some(value => !pathInside(access.root, resolve(access.root, value))))
|
|
810
|
+
return '伙伴心跳只能访问当前伙伴工作目录内的文件。';
|
|
811
|
+
if (!isUpdate)
|
|
812
|
+
return undefined;
|
|
813
|
+
if (name === 'str_replace_editor' && argumentsValue.command === 'create')
|
|
814
|
+
return '心跳只能更新已经关联且现存的 @文件,不能创建新文件。';
|
|
815
|
+
const target = paths[0];
|
|
816
|
+
if (paths.length !== 1 || target === undefined || !access || !access.writable.has(resolve(access.root, target))) {
|
|
817
|
+
return '心跳只能更新当前挂念明确关联的现存 @文件,不能修改其他文件。';
|
|
818
|
+
}
|
|
819
|
+
return undefined;
|
|
820
|
+
}
|
|
821
|
+
async function executeHeartbeatTool(ctx, agent, call, argumentsValue, signal, remainingDiscoveryMs) {
|
|
822
|
+
const configuredTimeoutMs = call.name === 'web_fetch' || call.name === 'web_source' ? HEARTBEAT_WEB_TOOL_TIMEOUT_MS : HEARTBEAT_TOOL_TIMEOUT_MS;
|
|
823
|
+
const timeoutMs = Math.max(1, Math.min(configuredTimeoutMs, remainingDiscoveryMs));
|
|
824
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
825
|
+
const toolSignal = AbortSignal.any([signal, timeoutSignal]);
|
|
826
|
+
try {
|
|
827
|
+
return await ctx.tools.execute({ callId: call.id, name: call.name, arguments: argumentsValue, agent, signal: toolSignal });
|
|
828
|
+
}
|
|
829
|
+
catch (error) {
|
|
830
|
+
const message = timeoutSignal.aborted && !signal.aborted
|
|
831
|
+
? `工具 ${call.name} 超过 ${timeoutMs / 1_000} 秒,已跳过;请按既有规则判断来源不可用,或根据已有结果收束。`
|
|
832
|
+
: `工具 ${call.name} 调用失败:${errorMessage(error)}`;
|
|
833
|
+
return { isError: true, content: [{ type: 'text', text: message }] };
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
function pathInside(root, target) {
|
|
837
|
+
const value = relative(root, target);
|
|
838
|
+
return value === '' || (value !== '..' && !value.startsWith(`..${sep}`) && !isAbsolute(value));
|
|
839
|
+
}
|
|
840
|
+
function boundHeartbeatToolArguments(name, value) {
|
|
841
|
+
if (!isRecord(value))
|
|
842
|
+
return value;
|
|
843
|
+
const bounded = { ...value };
|
|
844
|
+
if (name === 'knowledge_base_search' || name === 'knowledge_search')
|
|
845
|
+
bounded.limit = boundedInteger(value.limit, 4);
|
|
846
|
+
if (name === 'knowledge_read')
|
|
847
|
+
bounded.maxChars = boundedInteger(value.maxChars, HEARTBEAT_KNOWLEDGE_RESULT_LIMIT);
|
|
848
|
+
if (name === 'web_source')
|
|
849
|
+
bounded.max_chars = boundedInteger(value.max_chars, 120_000);
|
|
850
|
+
if (name === 'read')
|
|
851
|
+
bounded.limit = boundedInteger(value.limit, 80);
|
|
852
|
+
return bounded;
|
|
853
|
+
}
|
|
854
|
+
function heartbeatToolContent(name, content) {
|
|
855
|
+
let remaining = heartbeatToolResultLimit(name);
|
|
856
|
+
const bounded = [];
|
|
857
|
+
for (const block of content) {
|
|
858
|
+
if (remaining <= 0)
|
|
859
|
+
break;
|
|
860
|
+
if (block.type !== 'text') {
|
|
861
|
+
bounded.push(block);
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
const sanitized = name === 'glob'
|
|
865
|
+
? block.text.split('\n').filter(line => !isPartnerMemoryPath(line.trim())).join('\n')
|
|
866
|
+
: block.text;
|
|
867
|
+
const text = sanitized.length <= remaining ? sanitized : `${sanitized.slice(0, Math.max(0, remaining - 1))}…`;
|
|
868
|
+
if (text)
|
|
869
|
+
bounded.push({ ...block, text });
|
|
870
|
+
remaining -= text.length;
|
|
871
|
+
}
|
|
872
|
+
if (bounded.length === 0 && name === 'glob')
|
|
873
|
+
return [{ type: 'text', text: '未发现可供心跳检查的普通工作文件;伙伴记忆与会话归档已从结果中排除。' }];
|
|
874
|
+
return bounded;
|
|
875
|
+
}
|
|
876
|
+
function heartbeatToolResultLimit(name) {
|
|
877
|
+
if (name === 'knowledge_read')
|
|
878
|
+
return HEARTBEAT_KNOWLEDGE_RESULT_LIMIT;
|
|
879
|
+
if (name === 'web_fetch')
|
|
880
|
+
return HEARTBEAT_WEB_FETCH_RESULT_LIMIT;
|
|
881
|
+
if (name === 'web_source')
|
|
882
|
+
return HEARTBEAT_WEB_SOURCE_RESULT_LIMIT;
|
|
883
|
+
return HEARTBEAT_TOOL_RESULT_LIMIT;
|
|
884
|
+
}
|
|
885
|
+
function isPartnerMemoryPath(value) {
|
|
886
|
+
const normalized = value.replaceAll('\\', '/').replace(/^\.\//, '/');
|
|
887
|
+
return /(?:^|\/)(?:memory(?:\/|-backup(?:-|\/)|$)|concerns(?:\/|$))/i.test(normalized);
|
|
888
|
+
}
|
|
889
|
+
function boundedInteger(value, maximum) {
|
|
890
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? Math.min(value, maximum) : maximum;
|
|
891
|
+
}
|
|
892
|
+
function isRecord(value) {
|
|
893
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
894
|
+
}
|
|
895
|
+
export function renderHeartbeatActivity(execution, outcome, deliveryError) {
|
|
896
|
+
const status = outcome === 'notified' ? '已通过微信提醒' : outcome === 'quiet' ? '无需主动提醒' : '执行失败';
|
|
897
|
+
const lines = [
|
|
898
|
+
`状态:${status}`,
|
|
899
|
+
`本轮挂念:${execution.concerns.map(item => item.subject).join(';')}`,
|
|
900
|
+
`用时:${formatDuration(execution.completedAt - execution.startedAt)}`,
|
|
901
|
+
`工具:${execution.tools.length > 0 ? `${execution.tools.length} 次调用` : '未调用'}`,
|
|
902
|
+
];
|
|
903
|
+
execution.tools.forEach((trace, index) => {
|
|
904
|
+
lines.push('', `${index + 1}. ${trace.name} · ${trace.status === 'completed' ? '完成' : '失败'} · ${formatDuration(trace.completedAt - trace.startedAt)}`, `输入:${trace.input}`, `结果:${trace.output}`);
|
|
905
|
+
});
|
|
906
|
+
const changed = execution.observations ?? [];
|
|
907
|
+
const conclusion = execution.error || deliveryError
|
|
908
|
+
? `失败原因:${deliveryError ?? execution.error}`
|
|
909
|
+
: changed.length > 0
|
|
910
|
+
? `新变化:\n${changed.map(item => `- ${item.event}(${decisionLabel(item.decision)} · ${Math.round(item.interruptScore * 100)})`).join('\n')}`
|
|
911
|
+
: '最终结论:本轮没有发现经过校验的新变化。';
|
|
912
|
+
lines.push('', conclusion);
|
|
913
|
+
return lines.join('\n');
|
|
914
|
+
}
|
|
915
|
+
function decisionLabel(value) {
|
|
916
|
+
return value === 'notify' ? '已提醒' : value === 'feed' ? '伙伴动态' : value === 'defer' ? '顺手一提' : value === 'remember' ? '静默记下' : '忽略';
|
|
917
|
+
}
|
|
918
|
+
function auditValue(value, limit) {
|
|
919
|
+
try {
|
|
920
|
+
return auditText(JSON.stringify(value), limit);
|
|
921
|
+
}
|
|
922
|
+
catch {
|
|
923
|
+
return auditText(String(value), limit);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
function auditText(value, limit) {
|
|
927
|
+
const text = value.trim().replace(/\n{3,}/g, '\n\n');
|
|
928
|
+
return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`;
|
|
929
|
+
}
|
|
930
|
+
function formatDuration(milliseconds) {
|
|
931
|
+
if (milliseconds < 1_000)
|
|
932
|
+
return `${Math.max(0, milliseconds)} ms`;
|
|
933
|
+
return `${(milliseconds / 1_000).toFixed(milliseconds < 10_000 ? 1 : 0)} 秒`;
|
|
934
|
+
}
|
|
935
|
+
function errorMessage(error) { return error instanceof Error ? error.message : String(error); }
|
|
936
|
+
function renderPersona(companion, surface = 'conversation') {
|
|
456
937
|
const capabilities = companion.capabilities.length > 0 ? companion.capabilities.join('、') : '由当前 Agent Preset 提供的基础能力';
|
|
457
938
|
return [
|
|
458
939
|
`你当前以长期工作伙伴「${companion.name}」的身份工作。`,
|
|
@@ -460,7 +941,9 @@ function renderPersona(companion) {
|
|
|
460
941
|
companion.description ? `定位:${companion.description}` : '',
|
|
461
942
|
companion.instructions ? `长期行为准则:\n${companion.instructions}` : '',
|
|
462
943
|
`用户为此伙伴启用的能力范围:${capabilities}。能力标识不等于授权;只能调用当前会话实际提供且已经通过权限校验的工具。`,
|
|
463
|
-
|
|
944
|
+
surface === 'heartbeat'
|
|
945
|
+
? '这是一轮独立的伙伴心跳,不是用户聊天的延续。只根据本轮真实可读信息行动,最终结果由渠道适配器决定是否发送。'
|
|
946
|
+
: '这个会话由 DSH 网页与微信私聊渠道共同使用。保持同一上下文,不要假定每条消息都来自微信;渠道回传由适配器负责。回答兼顾网页与移动端阅读,执行外部操作前继续遵守工具自身的授权与审批边界。',
|
|
464
947
|
].filter(Boolean).join('\n\n');
|
|
465
948
|
}
|
|
466
949
|
export function renderToolProtocol() {
|