@lemoncat7/dsh-partner 1.0.0 → 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 +24 -7
- package/lib/agent-runtime.d.ts +53 -4
- package/lib/agent-runtime.d.ts.map +1 -1
- package/lib/agent-runtime.js +674 -52
- 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 +3 -0
- package/lib/autonomy.d.ts.map +1 -0
- package/lib/autonomy.js +44 -0
- package/lib/autonomy.js.map +1 -0
- package/lib/channel-message.d.ts +23 -0
- package/lib/channel-message.d.ts.map +1 -0
- package/lib/channel-message.js +8 -0
- package/lib/channel-message.js.map +1 -0
- package/lib/channels/manager.d.ts +28 -1
- package/lib/channels/manager.d.ts.map +1 -1
- package/lib/channels/manager.js +230 -15
- package/lib/channels/manager.js.map +1 -1
- package/lib/channels/weixin/api.d.ts +2 -0
- package/lib/channels/weixin/api.d.ts.map +1 -1
- package/lib/channels/weixin/api.js +80 -1
- package/lib/channels/weixin/api.js.map +1 -1
- package/lib/channels/weixin/media.d.ts +4 -0
- package/lib/channels/weixin/media.d.ts.map +1 -0
- package/lib/channels/weixin/media.js +78 -0
- package/lib/channels/weixin/media.js.map +1 -0
- package/lib/channels/weixin/types.d.ts +19 -2
- package/lib/channels/weixin/types.d.ts.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 +939 -481
- package/lib/client.js.map +4 -4
- 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 +39 -9
- 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/lib/workspace-ownership.d.ts +5 -0
- package/lib/workspace-ownership.d.ts.map +1 -0
- package/lib/workspace-ownership.js +18 -0
- package/lib/workspace-ownership.js.map +1 -0
- package/package.json +13 -2
package/lib/agent-runtime.js
CHANGED
|
@@ -1,28 +1,61 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdir } from 'node:fs/promises';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
2
|
+
import { mkdir, realpath, stat, writeFile } from 'node:fs/promises';
|
|
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';
|
|
8
|
+
import { PARTNER_MEDIA_MAX_BYTES, safeMediaName } from './channel-message.js';
|
|
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]);
|
|
5
34
|
export class PartnerAgentRuntime {
|
|
6
35
|
ctx;
|
|
7
36
|
store;
|
|
8
37
|
defaultCwd;
|
|
9
38
|
memory;
|
|
10
39
|
reflection;
|
|
40
|
+
concerns;
|
|
11
41
|
handles = new Map();
|
|
12
42
|
queues = new Map();
|
|
43
|
+
heartbeatQueues = new Map();
|
|
44
|
+
steeringQueues = new Map();
|
|
13
45
|
workspaces = new Map();
|
|
14
|
-
constructor(ctx, store, defaultCwd, memory, reflection) {
|
|
46
|
+
constructor(ctx, store, defaultCwd, memory, reflection, concerns) {
|
|
15
47
|
this.ctx = ctx;
|
|
16
48
|
this.store = store;
|
|
17
49
|
this.defaultCwd = defaultCwd;
|
|
18
50
|
this.memory = memory;
|
|
19
51
|
this.reflection = reflection;
|
|
52
|
+
this.concerns = concerns;
|
|
20
53
|
}
|
|
21
|
-
async reply(companion, channelId, userId,
|
|
54
|
+
async reply(companion, channelId, userId, message) {
|
|
22
55
|
const key = `${channelId}:${userId}`;
|
|
23
56
|
const previous = this.queues.get(key) ?? Promise.resolve();
|
|
24
|
-
let output = '';
|
|
25
|
-
const current = previous.catch(() => { }).then(async () => { output = await this.drive(companion, channelId, userId,
|
|
57
|
+
let output = { text: '', attachments: [] };
|
|
58
|
+
const current = previous.catch(() => { }).then(async () => { output = await this.drive(companion, channelId, userId, message); });
|
|
26
59
|
this.queues.set(key, current);
|
|
27
60
|
try {
|
|
28
61
|
await current;
|
|
@@ -33,6 +66,46 @@ export class PartnerAgentRuntime {
|
|
|
33
66
|
this.queues.delete(key);
|
|
34
67
|
}
|
|
35
68
|
}
|
|
69
|
+
async steer(companion, channelId, userId, message) {
|
|
70
|
+
const key = `${channelId}:${userId}`;
|
|
71
|
+
const previous = this.steeringQueues.get(key) ?? Promise.resolve();
|
|
72
|
+
let accepted = false;
|
|
73
|
+
const current = previous.catch(() => { }).then(async () => { accepted = await this.deliverSteering(companion, channelId, userId, message); });
|
|
74
|
+
this.steeringQueues.set(key, current);
|
|
75
|
+
try {
|
|
76
|
+
await current;
|
|
77
|
+
return accepted;
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
if (this.steeringQueues.get(key) === current)
|
|
81
|
+
this.steeringQueues.delete(key);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async deliverSteering(companion, channelId, userId, message) {
|
|
85
|
+
const route = this.store.snapshot().sessions.find(item => item.channelId === channelId && item.userId === userId && item.companionId === companion.id);
|
|
86
|
+
if (route === undefined)
|
|
87
|
+
return false;
|
|
88
|
+
const agent = this.handles.get(route.sessionId)?.agent ?? this.ctx.agents.get(route.sessionId);
|
|
89
|
+
if (agent === undefined || agent.status !== 'running')
|
|
90
|
+
return false;
|
|
91
|
+
const inbound = await this.persistInbound(route, message);
|
|
92
|
+
const recalled = await this.memory?.recall(companion.id, memoryScope(channelId, userId), inbound.query, 12).catch(() => []);
|
|
93
|
+
if (recalled && recalled.length > 0)
|
|
94
|
+
agent.inject(createUserMessage({
|
|
95
|
+
content: [{ type: 'text', text: renderMemory(recalled) }],
|
|
96
|
+
source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: '伙伴为插话召回了相关长期记忆' },
|
|
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
|
+
}
|
|
106
|
+
agent.steer(createUserMessage({ content: inbound.content, source: { kind: 'user' } }));
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
36
109
|
async prepareSession(routeId) {
|
|
37
110
|
const route = this.store.snapshot().sessions.find(item => item.id === routeId);
|
|
38
111
|
if (route === undefined)
|
|
@@ -58,29 +131,106 @@ export class PartnerAgentRuntime {
|
|
|
58
131
|
});
|
|
59
132
|
return next;
|
|
60
133
|
}
|
|
61
|
-
async heartbeat(companion, route) {
|
|
134
|
+
async heartbeat(companion, route, concerns) {
|
|
62
135
|
const key = `${route.channelId}:${route.userId}`;
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
this.
|
|
136
|
+
const queued = this.heartbeatQueues.get(key);
|
|
137
|
+
if (queued !== undefined)
|
|
138
|
+
return queued;
|
|
139
|
+
const current = this.runHeartbeatWhenIdle(key, companion, route, concerns);
|
|
140
|
+
this.heartbeatQueues.set(key, current);
|
|
67
141
|
try {
|
|
68
|
-
await current;
|
|
69
|
-
return output;
|
|
142
|
+
return await current;
|
|
70
143
|
}
|
|
71
144
|
finally {
|
|
72
|
-
if (this.
|
|
73
|
-
this.
|
|
145
|
+
if (this.heartbeatQueues.get(key) === current)
|
|
146
|
+
this.heartbeatQueues.delete(key);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
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) {
|
|
212
|
+
for (;;) {
|
|
213
|
+
await this.queues.get(key)?.catch(() => { });
|
|
214
|
+
const conversation = await this.ensureAgent(companion, route);
|
|
215
|
+
if (conversation.status !== 'idle')
|
|
216
|
+
await conversation.whenIdle();
|
|
217
|
+
const stableSeq = conversation.session.seq;
|
|
218
|
+
await delay(2_000);
|
|
219
|
+
if (this.queues.has(key))
|
|
220
|
+
continue;
|
|
221
|
+
if (conversation.status !== 'idle' || conversation.session.seq !== stableSeq)
|
|
222
|
+
continue;
|
|
223
|
+
return this.driveScopedHeartbeat(conversation, companion, concerns);
|
|
74
224
|
}
|
|
75
225
|
}
|
|
76
226
|
async observeSessionEvent(session, event) {
|
|
77
|
-
if (event.type !== 'turn/end' || event.data.reason.kind !== 'completed'
|
|
227
|
+
if (event.type !== 'turn/end' || event.data.reason.kind !== 'completed')
|
|
78
228
|
return;
|
|
79
229
|
const route = this.store.snapshot().sessions.find(item => item.sessionId === session.id);
|
|
80
230
|
if (route === undefined)
|
|
81
231
|
return;
|
|
82
232
|
const companion = this.store.snapshot().companions.find(item => item.id === route.companionId);
|
|
83
|
-
if (companion === undefined
|
|
233
|
+
if (companion === undefined)
|
|
84
234
|
return;
|
|
85
235
|
const events = completedTurnEvents(session.events, event);
|
|
86
236
|
const prompt = events.find(item => item.type === 'user/message' && item.data.source.kind === 'user');
|
|
@@ -89,42 +239,89 @@ export class PartnerAgentRuntime {
|
|
|
89
239
|
const assistantText = replies.flatMap(item => item.type === 'assistant/message' ? [textContent(item.data.message.content)] : []).filter(Boolean).join('\n\n');
|
|
90
240
|
if (!userText || !assistantText)
|
|
91
241
|
return;
|
|
92
|
-
|
|
93
|
-
|
|
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,
|
|
94
248
|
sessionId: session.id, at: event.time, user: userText, assistant: assistantText,
|
|
249
|
+
...(concernDirective === undefined ? {} : { concernDirective }),
|
|
95
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
|
+
});
|
|
96
255
|
await this.store.update(state => {
|
|
97
256
|
const target = state.sessions.find(item => item.id === route.id);
|
|
98
257
|
if (target)
|
|
99
258
|
target.lastMessageAt = Date.now();
|
|
100
259
|
});
|
|
101
260
|
}
|
|
102
|
-
async
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
content: [{ type: 'text', text:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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);
|
|
290
|
+
timeout.unref?.();
|
|
291
|
+
try {
|
|
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
|
+
};
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
clearTimeout(timeout);
|
|
310
|
+
}
|
|
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' });
|
|
128
325
|
}
|
|
129
326
|
async resetCompanion(companionId) {
|
|
130
327
|
await this.releaseCompanion(companionId);
|
|
@@ -153,21 +350,30 @@ export class PartnerAgentRuntime {
|
|
|
153
350
|
async close() {
|
|
154
351
|
await Promise.all([...this.handles.values()].map(handle => handle.dispose().catch(() => { })));
|
|
155
352
|
this.handles.clear();
|
|
353
|
+
this.heartbeatQueues.clear();
|
|
354
|
+
this.steeringQueues.clear();
|
|
156
355
|
}
|
|
157
|
-
async drive(companion, channelId, userId,
|
|
356
|
+
async drive(companion, channelId, userId, message) {
|
|
158
357
|
const session = await this.ensureSession(companion, channelId, userId);
|
|
159
358
|
const agent = await this.ensureAgent(companion, session);
|
|
160
359
|
if (agent.status !== 'idle')
|
|
161
360
|
await agent.whenIdle();
|
|
162
|
-
const
|
|
361
|
+
const inbound = await this.persistInbound(session, message);
|
|
362
|
+
const recalled = await this.memory?.recall(companion.id, memoryScope(channelId, userId), inbound.query, 12).catch(() => []);
|
|
163
363
|
if (recalled && recalled.length > 0)
|
|
164
364
|
agent.inject(createUserMessage({
|
|
165
365
|
content: [{ type: 'text', text: renderMemory(recalled) }],
|
|
166
366
|
source: { kind: 'plugin', plugin: '@lemoncat7/dsh-partner', form: 'notice', summary: '伙伴回忆了与当前消息相关的长期记忆' },
|
|
167
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
|
+
}));
|
|
168
374
|
const startSeq = agent.session.seq;
|
|
169
375
|
agent.followup(createUserMessage({
|
|
170
|
-
content:
|
|
376
|
+
content: inbound.content,
|
|
171
377
|
source: { kind: 'user' },
|
|
172
378
|
}));
|
|
173
379
|
await agent.whenIdle();
|
|
@@ -179,7 +385,35 @@ export class PartnerAgentRuntime {
|
|
|
179
385
|
});
|
|
180
386
|
if (!response)
|
|
181
387
|
throw new Error('伙伴没有生成可发送的文本回复');
|
|
182
|
-
|
|
388
|
+
if (deferred && deferred.length > 0)
|
|
389
|
+
await this.concerns?.markMentioned(companion.id, deferred.map(item => item.id)).catch(() => { });
|
|
390
|
+
return { text: response, attachments: await extractOutboundAttachments(response, session.cwd ?? this.defaultCwd) };
|
|
391
|
+
}
|
|
392
|
+
async persistInbound(session, message) {
|
|
393
|
+
const cwd = session.cwd ?? this.defaultCwd;
|
|
394
|
+
const directory = join(cwd, 'inbound');
|
|
395
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
396
|
+
const content = [];
|
|
397
|
+
const notes = [];
|
|
398
|
+
for (const attachment of message.attachments) {
|
|
399
|
+
if (attachment.data.byteLength > PARTNER_MEDIA_MAX_BYTES)
|
|
400
|
+
throw new Error('微信附件超过 64 MB 限制');
|
|
401
|
+
const name = `${Date.now()}-${randomUUID().slice(0, 8)}-${safeMediaName(attachment.name)}`;
|
|
402
|
+
const path = join(directory, name);
|
|
403
|
+
await writeFile(path, attachment.data, { flag: 'wx', mode: 0o600 });
|
|
404
|
+
notes.push(`微信${attachment.kind === 'image' ? '图片' : '文档'}已保存:${path}`);
|
|
405
|
+
if (attachment.kind === 'image') {
|
|
406
|
+
const mediaType = attachment.mediaType;
|
|
407
|
+
const ref = await this.ctx.attachments.saveImage({ data: attachment.data, mediaType, name: attachment.name });
|
|
408
|
+
content.push({ type: 'image', attachment: ref });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const visibleText = [message.text.trim(), ...notes].filter(Boolean).join('\n\n');
|
|
412
|
+
if (visibleText)
|
|
413
|
+
content.unshift({ type: 'text', text: visibleText });
|
|
414
|
+
if (content.length === 0)
|
|
415
|
+
content.push({ type: 'text', text: '[微信附件消息]' });
|
|
416
|
+
return { content, query: [message.text, ...message.attachments.map(item => item.name)].filter(Boolean).join(' ') };
|
|
183
417
|
}
|
|
184
418
|
async ensureSession(companion, channelId, userId) {
|
|
185
419
|
const existing = this.store.snapshot().sessions.find(item => item.channelId === channelId && item.userId === userId);
|
|
@@ -271,6 +505,65 @@ export class PartnerAgentRuntime {
|
|
|
271
505
|
await (await workspace).attachSession(route.sessionId);
|
|
272
506
|
}
|
|
273
507
|
}
|
|
508
|
+
function delay(milliseconds) {
|
|
509
|
+
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
510
|
+
}
|
|
511
|
+
export async function extractOutboundAttachments(text, cwd) {
|
|
512
|
+
const candidates = new Set();
|
|
513
|
+
for (const match of text.matchAll(/\[[^\]]*\]\((?:<)?(file:\/\/)?([^)>]+)(?:>)?\)/g)) {
|
|
514
|
+
const value = decodeURIComponent(match[2] ?? '').trim();
|
|
515
|
+
if (value)
|
|
516
|
+
candidates.add(value);
|
|
517
|
+
}
|
|
518
|
+
for (const match of text.matchAll(/`([^`\n]+)`/g)) {
|
|
519
|
+
const value = match[1]?.trim();
|
|
520
|
+
if (value && mediaTypeFor(value))
|
|
521
|
+
candidates.add(value);
|
|
522
|
+
}
|
|
523
|
+
for (const line of text.split('\n').map(item => item.trim())) {
|
|
524
|
+
const value = line.replace(/^`|`$/g, '');
|
|
525
|
+
if (value.startsWith('/') && !value.includes(' '))
|
|
526
|
+
candidates.add(value);
|
|
527
|
+
}
|
|
528
|
+
const root = await realpath(cwd);
|
|
529
|
+
const result = [];
|
|
530
|
+
const included = new Set();
|
|
531
|
+
for (const candidate of candidates) {
|
|
532
|
+
if (result.length >= 8)
|
|
533
|
+
break;
|
|
534
|
+
const target = resolve(root, candidate);
|
|
535
|
+
let actual;
|
|
536
|
+
try {
|
|
537
|
+
actual = await realpath(target);
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (included.has(actual))
|
|
543
|
+
continue;
|
|
544
|
+
const rel = relative(root, actual);
|
|
545
|
+
if (rel.startsWith('..') || rel === '..' || resolve(root, rel) !== actual)
|
|
546
|
+
continue;
|
|
547
|
+
const info = await stat(actual);
|
|
548
|
+
if (!info.isFile() || info.size > PARTNER_MEDIA_MAX_BYTES)
|
|
549
|
+
continue;
|
|
550
|
+
const mediaType = mediaTypeFor(actual);
|
|
551
|
+
if (!mediaType)
|
|
552
|
+
continue;
|
|
553
|
+
included.add(actual);
|
|
554
|
+
result.push({ path: actual, name: basename(actual), mediaType, kind: mediaType.startsWith('image/') ? 'image' : 'file' });
|
|
555
|
+
}
|
|
556
|
+
return result;
|
|
557
|
+
}
|
|
558
|
+
function mediaTypeFor(path) {
|
|
559
|
+
return {
|
|
560
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
|
|
561
|
+
'.pdf': 'application/pdf', '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
562
|
+
'.xls': 'application/vnd.ms-excel', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
563
|
+
'.ppt': 'application/vnd.ms-powerpoint', '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
564
|
+
'.txt': 'text/plain', '.md': 'text/markdown', '.csv': 'text/csv', '.json': 'application/json', '.zip': 'application/zip',
|
|
565
|
+
}[extname(path).toLowerCase()];
|
|
566
|
+
}
|
|
274
567
|
export function completedTurnEvents(events, end) {
|
|
275
568
|
let startSeq;
|
|
276
569
|
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
@@ -293,6 +586,12 @@ function renderMemory(entries) {
|
|
|
293
586
|
...entries.map(entry => `- [${entry.kind}|置信度 ${entry.confidence.toFixed(2)}] ${entry.subject}:${entry.content}`),
|
|
294
587
|
].join('\n');
|
|
295
588
|
}
|
|
589
|
+
function renderDeferredMentions(entries) {
|
|
590
|
+
return [
|
|
591
|
+
'伙伴之前发现了与用户当前消息相关的新变化。若自然且确实有帮助,可以在本次回答末尾顺手提一句;不要抢占当前问题,也不要声称刚刚搜索。',
|
|
592
|
+
...entries.map(item => `- ${item.event}${item.evidence ? `(依据:${item.evidence})` : ''}`),
|
|
593
|
+
].join('\n');
|
|
594
|
+
}
|
|
296
595
|
export function resolveAgentOptions(defaultModel, companion) {
|
|
297
596
|
return {
|
|
298
597
|
...defaultModel.currentSelection(),
|
|
@@ -314,7 +613,327 @@ export function renewedSession(previous, now = Date.now()) {
|
|
|
314
613
|
export function partnerCwd(root, companionId) {
|
|
315
614
|
return join(root, 'partners', companionId);
|
|
316
615
|
}
|
|
317
|
-
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') {
|
|
318
937
|
const capabilities = companion.capabilities.length > 0 ? companion.capabilities.join('、') : '由当前 Agent Preset 提供的基础能力';
|
|
319
938
|
return [
|
|
320
939
|
`你当前以长期工作伙伴「${companion.name}」的身份工作。`,
|
|
@@ -322,7 +941,9 @@ function renderPersona(companion) {
|
|
|
322
941
|
companion.description ? `定位:${companion.description}` : '',
|
|
323
942
|
companion.instructions ? `长期行为准则:\n${companion.instructions}` : '',
|
|
324
943
|
`用户为此伙伴启用的能力范围:${capabilities}。能力标识不等于授权;只能调用当前会话实际提供且已经通过权限校验的工具。`,
|
|
325
|
-
|
|
944
|
+
surface === 'heartbeat'
|
|
945
|
+
? '这是一轮独立的伙伴心跳,不是用户聊天的延续。只根据本轮真实可读信息行动,最终结果由渠道适配器决定是否发送。'
|
|
946
|
+
: '这个会话由 DSH 网页与微信私聊渠道共同使用。保持同一上下文,不要假定每条消息都来自微信;渠道回传由适配器负责。回答兼顾网页与移动端阅读,执行外部操作前继续遵守工具自身的授权与审批边界。',
|
|
326
947
|
].filter(Boolean).join('\n\n');
|
|
327
948
|
}
|
|
328
949
|
export function renderToolProtocol() {
|
|
@@ -332,6 +953,7 @@ export function renderToolProtocol() {
|
|
|
332
953
|
'`run_code` 的程序只返回完成当前任务所需的精简结果。需要顺序依赖时逐个 `await`,互不依赖的只读调用才可并行。',
|
|
333
954
|
'若工具结果提示 “only `run_code` is callable directly”,说明路由方式错误;立即在同一轮改用 `run_code` 重试。只有规范重试也失败时,才向用户说明真正的失败原因。',
|
|
334
955
|
'若当前请求原生暴露了目标工具,则可以直接调用该原生工具;不要臆造未出现在顶层清单或生成 SDK 中的工具。',
|
|
956
|
+
'需要把生成的图片或文档交给用户时,最终回复必须用 Markdown 链接明确引用伙伴工作目录中的文件,例如 `[下载文件](/绝对路径/文件.pdf)`;不要只说“文件在这里”。渠道会在确认真实路径位于伙伴工作目录后发送附件。',
|
|
335
957
|
].join('\n');
|
|
336
958
|
}
|
|
337
959
|
function extractAssistantText(agent, fromSeq) {
|