mioku-plugin-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/loop.ts ADDED
@@ -0,0 +1,597 @@
1
+ import * as fs from "node:fs";
2
+ import type {
3
+ Bot,
4
+ MessageEvent,
5
+ MultimodalContentItem,
6
+ SessionToolDefinition,
7
+ } from "mioku";
8
+ import { UnsupportedCapabilityError } from "mioku";
9
+ import type { AgentHost } from "../types";
10
+ import { buildTurnTools } from "../tools";
11
+ import { TurnSender } from "./send";
12
+ import { buildSystemPrompt } from "./prompt";
13
+ import { maybeCompact } from "./compaction";
14
+ import { cleanEmotionMarkers, stripThinkBlocks } from "./units";
15
+ import { describeImageUrls, extractMedia, formatMediaNote } from "./media";
16
+ import { downloadMediaItems, readImageDataUrl } from "./download";
17
+ import { TurnActivity } from "./activity";
18
+ import {
19
+ normalizePermissionLevel,
20
+ batchesActivity,
21
+ isQuietMode,
22
+ type FsPolicy,
23
+ } from "../tools/perm";
24
+ import type { BashReporter } from "../tools/bash";
25
+
26
+ interface AgentChatMessage {
27
+ role: "system" | "user" | "assistant" | "tool";
28
+ content: string | MultimodalContentItem[];
29
+ tool_call_id?: string;
30
+ }
31
+
32
+ interface PreparedInput {
33
+ messageId: string;
34
+ text: string;
35
+ content: string | MultimodalContentItem[];
36
+ attachments: number;
37
+ }
38
+
39
+ interface UserQueue {
40
+ running: boolean;
41
+ inbox: PreparedInput[];
42
+ controller: AbortController | null;
43
+ }
44
+
45
+ const queues = new Map<number, UserQueue>();
46
+
47
+ function getQueue(userId: number): UserQueue {
48
+ let queue = queues.get(userId);
49
+ if (!queue) {
50
+ queue = { running: false, inbox: [], controller: null };
51
+ queues.set(userId, queue);
52
+ }
53
+ return queue;
54
+ }
55
+
56
+ export function stopAgentTurn(
57
+ host: AgentHost,
58
+ userId: number,
59
+ ): { running: boolean; dropped: number; approvals: number } {
60
+ const queue = queues.get(userId);
61
+ const dropped = queue?.inbox.length ?? 0;
62
+ if (queue) queue.inbox.length = 0;
63
+ const approvals = host.approvals.cancelByUser(userId);
64
+ const running = Boolean(queue?.running && queue.controller);
65
+ queue?.controller?.abort();
66
+ host.logger.info(
67
+ `[agent] stop requested | user=${userId} running=${running} dropped=${dropped} approvals=${approvals}`,
68
+ );
69
+ return { running, dropped, approvals };
70
+ }
71
+
72
+ function kickQueue(
73
+ host: AgentHost,
74
+ event: MessageEvent,
75
+ userId: number,
76
+ queue: UserQueue,
77
+ ): void {
78
+ if (queue.running) return;
79
+ const next = queue.inbox.shift();
80
+ if (!next) return;
81
+ queue.running = true;
82
+ void drainTurns(host, event, userId, queue, next);
83
+ }
84
+
85
+ export function runAgentTurn(
86
+ host: AgentHost,
87
+ event: MessageEvent,
88
+ ): Promise<void> {
89
+ const userId = Number(event.user_id || event.sender?.user_id || 0);
90
+ if (!userId) return Promise.resolve();
91
+ const queue = getQueue(userId);
92
+
93
+ // 先占住轮次再准备输入,保证消息按到达顺序处理
94
+ const ownsTurn = !queue.running;
95
+ if (ownsTurn) queue.running = true;
96
+
97
+ return prepareInput(host, event, userId)
98
+ .then((input) => {
99
+ if (!input) {
100
+ if (ownsTurn) {
101
+ queue.running = false;
102
+ kickQueue(host, event, userId, queue);
103
+ }
104
+ return;
105
+ }
106
+ if (!ownsTurn) {
107
+ queue.inbox.push(input);
108
+ host.logger.info(
109
+ `[agent] steer queued | user=${userId} text=${truncate(input.text, 120)}`,
110
+ );
111
+ // prepareInput 期间上一轮可能刚好结束,这里补一次启动,避免消息被丢
112
+ kickQueue(host, event, userId, queue);
113
+ return;
114
+ }
115
+ return drainTurns(host, event, userId, queue, input);
116
+ })
117
+ .catch((err) => {
118
+ if (ownsTurn) {
119
+ queue.running = false;
120
+ kickQueue(host, event, userId, queue);
121
+ }
122
+ host.logger.error(`[agent] turn dispatch failed: ${err}`);
123
+ });
124
+ }
125
+
126
+ async function drainTurns(
127
+ host: AgentHost,
128
+ event: MessageEvent,
129
+ userId: number,
130
+ queue: UserQueue,
131
+ first: PreparedInput,
132
+ ): Promise<void> {
133
+ const controller = new AbortController();
134
+ queue.controller = controller;
135
+ try {
136
+ let next: PreparedInput | undefined = first;
137
+ while (next) {
138
+ await executeTurn(host, event, userId, next, queue, controller.signal);
139
+ next = queue.inbox.shift();
140
+ }
141
+ } finally {
142
+ queue.controller = null;
143
+ queue.running = false;
144
+ kickQueue(host, event, userId, queue);
145
+ }
146
+ }
147
+
148
+ async function prepareInput(
149
+ host: AgentHost,
150
+ event: MessageEvent,
151
+ userId: number,
152
+ ): Promise<PreparedInput | null> {
153
+ const resolved = host.resolveModel();
154
+ const bot = event.bot ?? host.ctx.pickBot(event.self_id);
155
+ const media = extractMedia(event);
156
+ const downloads = await downloadMediaItems(
157
+ media,
158
+ host.workspaceRoot(userId),
159
+ {
160
+ bot,
161
+ },
162
+ ).catch((err) => {
163
+ host.logger.warn(`[agent] attachment download failed: ${err}`);
164
+ return { dir: "", files: [], errors: [String(err)] };
165
+ });
166
+
167
+ const imageFiles = downloads.files.filter((item) => item.kind === "image");
168
+ const attachSources: string[] = [];
169
+ const describeSources: string[] = [];
170
+ for (const item of imageFiles) {
171
+ if (item.remoteUrl) attachSources.push(item.remoteUrl);
172
+ try {
173
+ const dataUrl = await readImageDataUrl(item.path);
174
+ describeSources.push(dataUrl);
175
+ if (!item.remoteUrl) attachSources.push(dataUrl);
176
+ } catch (err) {
177
+ host.logger.warn(`[agent] failed to read downloaded image: ${err}`);
178
+ if (item.remoteUrl) describeSources.push(item.remoteUrl);
179
+ }
180
+ }
181
+
182
+ let text = host.ctx.text(event) || "";
183
+ if (resolved && !resolved.isMultimodal && describeSources.length > 0) {
184
+ const description = await describeImageUrls(host, describeSources);
185
+ if (description) {
186
+ text = `[The user sent ${describeSources.length} image(s). Image description: ${description}]\n\n${text}`;
187
+ }
188
+ }
189
+ const note = formatMediaNote(downloads.files, downloads.errors);
190
+ if (!text.trim()) text = note ? "(sent file)" : "";
191
+ if (!text.trim()) return null;
192
+ if (note) text = `${text}\n\n${note}`;
193
+
194
+ const messageId = String(event.message_id ?? "");
195
+ const header = `[User message]${messageId ? ` message_id=${messageId}` : ""}`;
196
+ const content: string | MultimodalContentItem[] =
197
+ resolved?.isMultimodal && attachSources.length > 0
198
+ ? [
199
+ { type: "text", text: `${header}\n${text}` },
200
+ ...attachSources.map(
201
+ (url): MultimodalContentItem => ({
202
+ type: "image_url",
203
+ image_url: { url, detail: "auto" },
204
+ }),
205
+ ),
206
+ ]
207
+ : `${header}\n${text}`;
208
+
209
+ return {
210
+ messageId,
211
+ text,
212
+ content,
213
+ attachments: downloads.files.length,
214
+ };
215
+ }
216
+
217
+ function steeringMessages(
218
+ host: AgentHost,
219
+ userId: number,
220
+ queue: UserQueue,
221
+ ): AgentChatMessage[] {
222
+ if (queue.inbox.length === 0) return [];
223
+ const pending = queue.inbox.splice(0, queue.inbox.length);
224
+ for (const item of pending) {
225
+ host.sessions.append(userId, "user", item.text);
226
+ }
227
+ host.logger.info(
228
+ `[agent] steer merged into running turn | user=${userId} count=${pending.length}`,
229
+ );
230
+ return pending.map((item) => ({
231
+ role: "user" as const,
232
+ content: steeringContent(item),
233
+ }));
234
+ }
235
+
236
+ function steeringContent(
237
+ item: PreparedInput,
238
+ ): string | MultimodalContentItem[] {
239
+ const notice =
240
+ "[User message sent while you were still working — read it and address it before finishing]";
241
+ if (typeof item.content === "string") {
242
+ return `${notice}\n${item.content}`;
243
+ }
244
+ const [first, ...rest] = item.content;
245
+ const firstText = first && first.type === "text" ? (first.text ?? "") : "";
246
+ return [{ type: "text", text: `${notice}\n${firstText}` }, ...rest];
247
+ }
248
+
249
+ async function executeTurn(
250
+ host: AgentHost,
251
+ event: MessageEvent,
252
+ userId: number,
253
+ input: PreparedInput,
254
+ queue: UserQueue,
255
+ abortSignal: AbortSignal,
256
+ ): Promise<void> {
257
+ const base = host.getBase();
258
+ const settings = host.getSettings();
259
+ const resolved = host.resolveModel();
260
+ const bot: Bot | undefined = event.bot ?? host.ctx.pickBot(event.self_id);
261
+ const level = normalizePermissionLevel(base.permissionLevel);
262
+ const digestMode = batchesActivity(level);
263
+ const activity = new TurnActivity(digestMode);
264
+
265
+ if (!resolved) {
266
+ await replyError(host, bot, userId, "AI 服务不可用,请先在 WebUI 配置模型");
267
+ return;
268
+ }
269
+
270
+ const session = host.sessions.get(userId);
271
+ const shared = await host.getChatShared();
272
+ const persona = shared.persona;
273
+ const currentEmotion = host.emotions.getCurrent(
274
+ session.emotion,
275
+ shared.emotion,
276
+ );
277
+ const runId = settings.dataCollection.enabled
278
+ ? host.db.startRun(session.sessionId, userId, resolved.model)
279
+ : 0;
280
+ const startedAt = Date.now();
281
+ let iterations = 0;
282
+ let toolCallCount = 0;
283
+ let status: "ok" | "error" = "ok";
284
+ let errorText = "";
285
+ activity.setSubject(input.text);
286
+
287
+ const sendToUser = async (text: string): Promise<void> => {
288
+ if (!bot) return;
289
+ await bot.sendMessage({ type: "private", user_id: userId }, [
290
+ host.ctx.segment.text(text),
291
+ ]);
292
+ };
293
+
294
+ const reporter: BashReporter = {
295
+ approval: async (notice) => {
296
+ if (!bot || isQuietMode(level)) return;
297
+ await sendToUser(
298
+ [
299
+ `Agent 请求执行命令(${notice.level}):`,
300
+ notice.command,
301
+ `用途:${notice.purpose}`,
302
+ `风险:${notice.reason || "需要审批"}`,
303
+ "回复 .agent approve 批准,.agent deny 拒绝",
304
+ ].join("\n"),
305
+ );
306
+ },
307
+ announce: async (notice) => {
308
+ if (!bot || isQuietMode(level) || digestMode) return;
309
+ const detail = notice.reason ? `(${notice.reason})` : "";
310
+ await sendToUser(
311
+ [
312
+ `Agent 执行命令(${notice.level})${detail}:`,
313
+ notice.command,
314
+ `用途:${notice.purpose}`,
315
+ ].join("\n"),
316
+ );
317
+ },
318
+ record: (notice, result, execStartedAt) => {
319
+ activity.recordBash(notice, result, execStartedAt);
320
+ },
321
+ };
322
+
323
+ try {
324
+ host.logger.info(
325
+ `[agent] turn start | user=${userId} session=${session.sessionId} model=${resolved.model} level=${level} files=${input.attachments} text=${truncate(input.text, 120)}`,
326
+ );
327
+
328
+ fs.mkdirSync(host.workspaceRoot(userId), { recursive: true });
329
+ await maybeCompact(host, userId);
330
+ const sessionRow = host.sessions.get(userId);
331
+ const priorHistory = host.sessions.history(sessionRow);
332
+
333
+ const { tools, webSearchState } = buildTurnTools(host, {
334
+ userId,
335
+ bot,
336
+ runId,
337
+ reporter,
338
+ activity,
339
+ });
340
+ const guardedTools = guardWebSearch(
341
+ tools,
342
+ webSearchState,
343
+ settings.webSearch.maxSearchCount,
344
+ );
345
+
346
+ const policy: FsPolicy = {
347
+ level,
348
+ workspaceRoot: host.workspaceRoot(userId),
349
+ };
350
+ const systemPrompt = buildSystemPrompt({
351
+ persona,
352
+ replyStyle: shared.replyStyle,
353
+ base,
354
+ settings,
355
+ policy,
356
+ currentEmotion,
357
+ emotion: shared.emotion,
358
+ toolNames: guardedTools.map((item) => item.name),
359
+ goal: sessionRow.goal,
360
+ plan: sessionRow.plan,
361
+ });
362
+
363
+ const messages: AgentChatMessage[] = [
364
+ { role: "system", content: systemPrompt },
365
+ ];
366
+ if (sessionRow.summary) {
367
+ messages.push({
368
+ role: "system",
369
+ content: `## Conversation Summary (older context)\n${sessionRow.summary}`,
370
+ });
371
+ }
372
+ for (const message of priorHistory) {
373
+ messages.push({ role: message.role, content: message.content });
374
+ }
375
+ messages.push({ role: "user", content: input.content });
376
+ host.sessions.append(userId, "user", input.text);
377
+
378
+ if (settings.debug) {
379
+ host.logger.info("[agent] === System Prompt ===");
380
+ host.logger.info(systemPrompt);
381
+ host.logger.info("[agent] === Request Messages ===");
382
+ for (const message of messages) {
383
+ host.logger.info(
384
+ `[agent] [${message.role}] ${describeMessageContent(message.content)}`,
385
+ );
386
+ }
387
+ host.logger.info("[agent] === End Request Messages ===");
388
+ }
389
+
390
+ const sender = new TurnSender(
391
+ host,
392
+ bot,
393
+ userId,
394
+ settings.enableMarkdownScreenshot && Boolean(host.screenshot),
395
+ );
396
+ const usageId = `agent:${sessionRow.sessionId}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
397
+ const usageContext = {
398
+ usageId,
399
+ source: "agent",
400
+ botId: Number(event?.self_id) || undefined,
401
+ userId,
402
+ sessionId: sessionRow.sessionId,
403
+ };
404
+
405
+ const runComplete = () =>
406
+ resolved.instance.complete({
407
+ model: resolved.model,
408
+ messages,
409
+ temperature: settings.temperature,
410
+ maxIterations: settings.maxIterations,
411
+ stream: settings.stream,
412
+ executableToolsProvider: () => guardedTools,
413
+ steeringProvider: () => steeringMessages(host, userId, queue),
414
+ abortSignal,
415
+ onTextDelta:
416
+ settings.stream && !digestMode
417
+ ? (delta) => sender.onDelta(delta)
418
+ : undefined,
419
+ usageContext,
420
+ });
421
+ const response = resolved.instance.withUsageContext
422
+ ? await resolved.instance.withUsageContext(usageContext, runComplete)
423
+ : await runComplete();
424
+
425
+ iterations = Number(response.iterations ?? 0);
426
+ toolCallCount = response.allToolCalls?.length ?? 0;
427
+
428
+ if (response.stopped) {
429
+ host.logger.info(
430
+ `[agent] turn stopped by user | user=${userId} iterations=${iterations}`,
431
+ );
432
+ return;
433
+ }
434
+
435
+ if (settings.debug) {
436
+ host.logger.info("[agent] === Raw AI Reply ===");
437
+ host.logger.info(response.content || "(empty)");
438
+ if (response.reasoning) {
439
+ host.logger.info(`[agent] reasoning: ${response.reasoning}`);
440
+ }
441
+ for (const call of response.allToolCalls ?? []) {
442
+ host.logger.info(
443
+ `[agent] tool call: ${call.name}(${JSON.stringify(call.arguments ?? {}).slice(0, 300)})`,
444
+ );
445
+ host.logger.info(
446
+ `[agent] tool result: ${call.name} -> ${JSON.stringify(call.result ?? null).slice(0, 500)}`,
447
+ );
448
+ }
449
+ host.logger.info("[agent] === End Raw AI Reply ===");
450
+ }
451
+
452
+ let finalText = stripThinkBlocks(response.content || "");
453
+ const { text: cleanedText, emotion } = cleanEmotionMarkers(finalText);
454
+ finalText = cleanedText;
455
+ if (emotion) {
456
+ const nextEmotion = host.emotions.setEmotion(
457
+ userId,
458
+ emotion,
459
+ shared.emotion,
460
+ );
461
+ if (settings.debug) {
462
+ host.logger.info(`[agent] emotion -> ${nextEmotion}`);
463
+ }
464
+ }
465
+
466
+ // 批量模式:把本回合的全部操作合并成一条转发记录,放在最终回复之前
467
+ await flushActivity(host, activity, bot, userId, event);
468
+
469
+ if (settings.stream && !digestMode) {
470
+ await sender.finishStream(finalText);
471
+ if (!finalText.trim()) finalText = sender.streamedText;
472
+ } else if (finalText.trim()) {
473
+ await sender.sendText(finalText);
474
+ }
475
+ if (finalText.trim()) {
476
+ host.sessions.append(userId, "assistant", finalText);
477
+ }
478
+
479
+ host.logger.info(
480
+ `[agent] turn done | user=${userId} iterations=${iterations || "?"} tools=${toolCallCount} ops=${activity.total} reply=${finalText.length}chars duration=${Date.now() - startedAt}ms`,
481
+ );
482
+ } catch (err) {
483
+ status = "error";
484
+ errorText = String(err);
485
+ host.logger.error(`[agent] turn failed: ${err}`);
486
+ await flushActivity(host, activity, bot, userId, event);
487
+ await replyError(
488
+ host,
489
+ bot,
490
+ userId,
491
+ `Agent 处理出错:${errorText.slice(0, 300)}`,
492
+ );
493
+ } finally {
494
+ if (runId > 0) {
495
+ host.db.finishRun(
496
+ runId,
497
+ status,
498
+ iterations,
499
+ toolCallCount,
500
+ Date.now() - startedAt,
501
+ errorText,
502
+ );
503
+ }
504
+ }
505
+ }
506
+
507
+ function truncate(text: string, max: number): string {
508
+ const value = String(text ?? "")
509
+ .replace(/\s+/g, " ")
510
+ .trim();
511
+ return value.length > max ? `${value.slice(0, max)}…` : value;
512
+ }
513
+
514
+ function describeMessageContent(
515
+ content: string | MultimodalContentItem[],
516
+ ): string {
517
+ if (typeof content === "string") return content;
518
+ return content
519
+ .map((part) =>
520
+ part.type === "text" ? String(part.text ?? "") : `[${String(part.type)}]`,
521
+ )
522
+ .join(" ");
523
+ }
524
+
525
+ function guardWebSearch(
526
+ tools: SessionToolDefinition[],
527
+ state: { count: number },
528
+ maxSearchCount: number,
529
+ ): SessionToolDefinition[] {
530
+ if (maxSearchCount <= 0) return tools;
531
+ return tools.map((definition) => {
532
+ if (definition.name !== "web_search") return definition;
533
+ return {
534
+ name: definition.name,
535
+ tool: {
536
+ ...definition.tool,
537
+ handler: async (args: Record<string, unknown>) => {
538
+ if (state.count >= maxSearchCount) {
539
+ return {
540
+ success: false,
541
+ error: `web_search limit (${maxSearchCount}) reached for this conversation. Answer from what you already found instead of searching again.`,
542
+ };
543
+ }
544
+ return definition.tool.handler(args);
545
+ },
546
+ },
547
+ };
548
+ });
549
+ }
550
+
551
+ async function flushActivity(
552
+ host: AgentHost,
553
+ activity: TurnActivity,
554
+ bot: Bot | undefined,
555
+ userId: number,
556
+ event: MessageEvent,
557
+ ): Promise<void> {
558
+ if (!bot || activity.total === 0) return;
559
+ const target = { type: "private" as const, user_id: userId };
560
+ const selfId = String(
561
+ event.self_id || host.ctx.bot?.bot_id || bot.bot_id || userId,
562
+ );
563
+ const nickname = host.ctx.bot?.nickname ?? "Agent";
564
+ const nodes = activity.buildNodes(host.ctx, selfId, nickname);
565
+ try {
566
+ await bot.sendForward(target, nodes, activity.buildDisplay());
567
+ host.logger.info(
568
+ `[agent] activity digest sent | user=${userId} nodes=${nodes.length}`,
569
+ );
570
+ return;
571
+ } catch (err) {
572
+ if (!(err instanceof UnsupportedCapabilityError)) {
573
+ host.logger.warn(`[agent] forward digest failed, falling back: ${err}`);
574
+ }
575
+ }
576
+ try {
577
+ await bot.sendMessage(target, activity.buildFallbackSegments(host.ctx));
578
+ } catch (err) {
579
+ host.logger.warn(`[agent] activity digest fallback failed: ${err}`);
580
+ }
581
+ }
582
+
583
+ async function replyError(
584
+ host: AgentHost,
585
+ bot: Bot | undefined,
586
+ userId: number,
587
+ text: string,
588
+ ): Promise<void> {
589
+ if (!bot) return;
590
+ try {
591
+ await bot.sendMessage({ type: "private", user_id: userId }, [
592
+ host.ctx.segment.text(text),
593
+ ]);
594
+ } catch {
595
+ host.logger.warn(`[agent] failed to deliver error message to ${userId}`);
596
+ }
597
+ }