mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 18:08:04 +00:00
Compare commits
70 Commits
release/v1
...
v1.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
| fddefc78a1 | |||
| 3f431063e2 | |||
| 18d7ad3a36 | |||
| 201a02839e | |||
| f0be8cbaa5 | |||
| 9f80289a89 | |||
| ae9b06976c | |||
| 6e6035141a | |||
| aba1770701 | |||
| cba33d2a6f | |||
| 2ebbdadcd6 | |||
| 48cfa8fb6c | |||
| 93aa942e1e | |||
| c5b1242980 | |||
| f0562915db | |||
| 818ce2698f | |||
| 36e7556a02 | |||
| 069552e921 | |||
| f7c3a4e81d | |||
| a2dcd96fee | |||
| 3c961b7bd7 | |||
| f2ec46480e | |||
| 3956610134 | |||
| fc41e160db | |||
| 158395bfdf | |||
| f3232000f4 | |||
| cb32032f76 | |||
| 86a702b3b0 | |||
| d4764e3682 | |||
| 8372f5bd8f | |||
| 98d67559d1 | |||
| 6bd3313a22 | |||
| f11de37bf2 | |||
| 4e4e0ad656 | |||
| a971fc8877 | |||
| 7565248f61 | |||
| c69a1f11af | |||
| 716ee46471 | |||
| 7082a50365 | |||
| 9a588780b9 | |||
| 06edff1af2 | |||
| 163c5f70b3 | |||
| ca5e62f775 | |||
| 07e08352b9 | |||
| 8f40b91dea | |||
| d1662e77cf | |||
| dc4982464d | |||
| fe883d5362 | |||
| 5fbd6514c2 | |||
| 5aaae93367 | |||
| 46e014a8ca | |||
| ea21803acb | |||
| 10367b384e | |||
| ce8bc27db8 | |||
| 7318a10828 | |||
| b15d0bd114 | |||
| 34628720f2 | |||
| 85223fc1c9 | |||
| dc80855540 | |||
| 0b1c201b5c | |||
| 5e10f012cb | |||
| 727768e083 | |||
| 0247206dfe | |||
| fbd3adabea | |||
| f778c613f1 | |||
| 7a418346a4 | |||
| a070cb563d | |||
| 685ed7e4a0 | |||
| dd99105d66 | |||
| 1fb5700662 |
@ -528,13 +528,17 @@ func main() {
|
|||||||
Personality: personality,
|
Personality: personality,
|
||||||
// 人格落库面:首启门禁(任何通道都问一次)与 persona_set 工具用。
|
// 人格落库面:首启门禁(任何通道都问一次)与 persona_set 工具用。
|
||||||
// 与 WebUI 向导共用 internal/config 的同一份落库逻辑。
|
// 与 WebUI 向导共用 internal/config 的同一份落库逻辑。
|
||||||
PersonaStore: internalConfig.RegistryPersonaStore{Reg: cfgReg},
|
PersonaStore: internalConfig.RegistryPersonaStore{Reg: cfgReg},
|
||||||
PluginReg: pluginReg,
|
PluginReg: pluginReg,
|
||||||
PluginDir: cfg.Plugin.Dir,
|
PluginDir: cfg.Plugin.Dir,
|
||||||
|
// DataDir:驻留子的 temp 图库锚点(<data>/residents/<id>/graph.db)。
|
||||||
|
// 漏接时的现象是"工具存在、可调用、但创建必失败"——只有真实二进制才看得出来。
|
||||||
|
DataDir: cfg.Daemon.DataDir,
|
||||||
DistillInterval: cfgReg.GetDuration("core.agent.distill_interval", 30*time.Minute),
|
DistillInterval: cfgReg.GetDuration("core.agent.distill_interval", 30*time.Minute),
|
||||||
ArchiveInterval: cfgReg.GetDuration("core.agent.archive_interval", 60*time.Minute),
|
ArchiveInterval: cfgReg.GetDuration("core.agent.archive_interval", 60*time.Minute),
|
||||||
ReviewInterval: cfgReg.GetDuration("core.agent.review_interval", 120*time.Minute),
|
ReviewInterval: cfgReg.GetDuration("core.agent.review_interval", 120*time.Minute),
|
||||||
MergeInterval: cfgReg.GetDuration("core.agent.merge_interval", 120*time.Minute),
|
MergeInterval: cfgReg.GetDuration("core.agent.merge_interval", 120*time.Minute),
|
||||||
|
MaxToolTurns: cfgReg.GetInt("core.agent.max_tool_turns", 10),
|
||||||
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
|
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
|
||||||
EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""),
|
EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""),
|
||||||
Embedder: embedder,
|
Embedder: embedder,
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"bundleName": "com.example.homeagent",
|
"bundleName": "com.example.homeagent",
|
||||||
"vendor": "HomeAgent",
|
"vendor": "HomeAgent",
|
||||||
"versionCode": 1000000,
|
"versionCode": 1001001,
|
||||||
"versionName": "1.0.0",
|
"versionName": "1.1.1",
|
||||||
// 分层图标:前景是字形,背景(沉淀色)在 base/ 与 dark/ 各一份,随系统主题切换。
|
// 分层图标:前景是字形,背景(沉淀色)在 base/ 与 dark/ 各一份,随系统主题切换。
|
||||||
// 直接指向位图会把浅色底烧进图标,深色模式下桌面和启动页都会跳脱。
|
// 直接指向位图会把浅色底烧进图标,深色模式下桌面和启动页都会跳脱。
|
||||||
"icon": "$media:layered_image",
|
"icon": "$media:layered_image",
|
||||||
|
|||||||
@ -32,6 +32,10 @@ export class ApiClient {
|
|||||||
this.conn = conn;
|
this.conn = conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearConnection(): void {
|
||||||
|
this.conn = null;
|
||||||
|
}
|
||||||
|
|
||||||
getConnection(): ConnectionConfig | null {
|
getConnection(): ConnectionConfig | null {
|
||||||
return this.conn;
|
return this.conn;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,16 +4,63 @@ import { pasteboard } from '@kit.BasicServicesKit';
|
|||||||
import { deviceInfo } from '@kit.BasicServicesKit';
|
import { deviceInfo } from '@kit.BasicServicesKit';
|
||||||
import { textToSpeech } from '@kit.CoreSpeechKit';
|
import { textToSpeech } from '@kit.CoreSpeechKit';
|
||||||
import { componentSnapshot } from '@kit.ArkUI';
|
import { componentSnapshot } from '@kit.ArkUI';
|
||||||
import { common } from '@kit.AbilityKit';
|
import { abilityAccessCtrl, common, PermissionRequestResult, Permissions } from '@kit.AbilityKit';
|
||||||
|
|
||||||
// ===== 能力结果 =====
|
// ===== 能力结果 =====
|
||||||
|
|
||||||
|
/** 与 BridgeRouter 实际支持的本机命令保持一一对应。 */
|
||||||
|
export const LOCAL_DEVICE_CAPS: string[] = [
|
||||||
|
'status',
|
||||||
|
'deviceinfo',
|
||||||
|
'screensee',
|
||||||
|
'screensue',
|
||||||
|
'clipboardsee',
|
||||||
|
'clipboardsue',
|
||||||
|
'speakeruse',
|
||||||
|
];
|
||||||
|
|
||||||
export interface CapResult {
|
export interface CapResult {
|
||||||
status: string; // 'ok' | 'error'
|
status: string; // 'ok' | 'error'
|
||||||
output: string;
|
output: string;
|
||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DeviceStatusPayload {
|
||||||
|
device_id: string;
|
||||||
|
status: string;
|
||||||
|
hostname: string;
|
||||||
|
platform: string;
|
||||||
|
arch: string;
|
||||||
|
uptime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceDetails {
|
||||||
|
hostname: string;
|
||||||
|
platform: string;
|
||||||
|
arch: string;
|
||||||
|
os_release: string;
|
||||||
|
version: string;
|
||||||
|
cpus: number;
|
||||||
|
brand: string;
|
||||||
|
manufacturer: string;
|
||||||
|
model: string;
|
||||||
|
series: string;
|
||||||
|
sdk_api_version: number;
|
||||||
|
security_patch: string;
|
||||||
|
abi_list: string;
|
||||||
|
device_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceInfoPayload {
|
||||||
|
device_id: string;
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
caps: string[];
|
||||||
|
info: DeviceDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
const APP_STARTED_AT: number = Date.now();
|
||||||
|
|
||||||
function okResult(output: string): CapResult {
|
function okResult(output: string): CapResult {
|
||||||
const r: CapResult = { status: 'ok', output: output, error: '' };
|
const r: CapResult = { status: 'ok', output: output, error: '' };
|
||||||
return r;
|
return r;
|
||||||
@ -44,53 +91,79 @@ async function captureScreenPixelMap(): Promise<image.PixelMap> {
|
|||||||
* 此处回传应用自身前台画面;应用在前台运行时即为用户正在看到的界面。
|
* 此处回传应用自身前台画面;应用在前台运行时即为用户正在看到的界面。
|
||||||
*/
|
*/
|
||||||
export async function capScreensee(): Promise<CapResult> {
|
export async function capScreensee(): Promise<CapResult> {
|
||||||
|
let full: image.PixelMap | null = null;
|
||||||
|
let packer: image.ImagePacker | null = null;
|
||||||
try {
|
try {
|
||||||
const full: image.PixelMap = await captureScreenPixelMap();
|
full = await captureScreenPixelMap();
|
||||||
const info: image.ImageInfo = await full.getImageInfo();
|
const info: image.ImageInfo = await full.getImageInfo();
|
||||||
const maxW: number = 720;
|
const maxW: number = 420;
|
||||||
let targetW: number = info.size.width;
|
const maxH: number = 640;
|
||||||
let targetH: number = info.size.height;
|
let scale: number = 1;
|
||||||
if (targetW > maxW) {
|
if (info.size.width > maxW) {
|
||||||
targetH = Math.floor(targetH * maxW / targetW);
|
scale = maxW / info.size.width;
|
||||||
targetW = maxW;
|
|
||||||
}
|
}
|
||||||
let packed: ArrayBuffer;
|
if (info.size.height * scale > maxH) {
|
||||||
if (targetW !== info.size.width) {
|
scale = maxH / info.size.height;
|
||||||
await full.scale(targetW / info.size.width, targetH / info.size.height);
|
|
||||||
}
|
}
|
||||||
const packer: image.ImagePacker = image.createImagePacker();
|
if (scale < 1) {
|
||||||
const opt: image.PackingOption = { format: 'image/jpeg', quality: 70 };
|
await full.scale(scale, scale);
|
||||||
packed = await packer.packing(full, opt);
|
}
|
||||||
packer.release();
|
packer = image.createImagePacker();
|
||||||
full.release();
|
const opt: image.PackingOption = { format: 'image/jpeg', quality: 55 };
|
||||||
|
const packed: ArrayBuffer = await packer.packing(full, opt);
|
||||||
const helper: util.Base64Helper = new util.Base64Helper();
|
const helper: util.Base64Helper = new util.Base64Helper();
|
||||||
const b64: string = helper.encodeToStringSync(new Uint8Array(packed));
|
const b64: string = helper.encodeToStringSync(new Uint8Array(packed));
|
||||||
|
if (b64.length > 950000) {
|
||||||
|
return errResult('当前画面数据过大,请稍后重试');
|
||||||
|
}
|
||||||
return okResult('data:image/jpeg;base64,' + b64);
|
return okResult('data:image/jpeg;base64,' + b64);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg: string = e instanceof Error ? e.message : String(e);
|
return errResult('无法读取当前应用画面,请保持应用在前台后重试');
|
||||||
return errResult('screensee failed: ' + msg);
|
} finally {
|
||||||
|
if (packer !== null) {
|
||||||
|
packer.release();
|
||||||
|
}
|
||||||
|
if (full !== null) {
|
||||||
|
full.release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== clipboardsee / clipboardsue =====
|
// ===== clipboardsee / clipboardsue =====
|
||||||
|
|
||||||
|
const CLIPBOARD_PERMISSIONS: Array<Permissions> = ['ohos.permission.READ_PASTEBOARD'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* READ_PASTEBOARD 是 user_grant 权限:仅在 agent 真正请求 clipboardsee 时弹出系统授权,
|
||||||
|
* 不在应用启动时抢先索权。已授权时系统会直接返回,不会重复打扰用户。
|
||||||
|
*/
|
||||||
|
async function ensureClipboardPermission(context: common.UIAbilityContext): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const atManager = abilityAccessCtrl.createAtManager();
|
||||||
|
const result: PermissionRequestResult =
|
||||||
|
await atManager.requestPermissionsFromUser(context, CLIPBOARD_PERMISSIONS);
|
||||||
|
return result.authResults.length > 0 && result.authResults[0] === 0;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function capClipboardSee(context: common.UIAbilityContext): Promise<CapResult> {
|
export async function capClipboardSee(context: common.UIAbilityContext): Promise<CapResult> {
|
||||||
// 说明:READ_PASTEBOARD 为受限权限,调试签名无法在真机安装时授予,
|
const granted: boolean = await ensureClipboardPermission(context);
|
||||||
// 这里直接尝试读取;系统拒绝时回错误信息。
|
if (!granted) {
|
||||||
|
return errResult('剪贴板读取权限未授予,请在系统设置中允许后重试');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
|
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
|
||||||
const has: boolean = await clip.hasData();
|
const has: boolean = await clip.hasData();
|
||||||
if (!has) {
|
if (!has) {
|
||||||
const empty: CapResult = { status: 'ok', output: '', error: '' };
|
return okResult('');
|
||||||
return empty;
|
|
||||||
}
|
}
|
||||||
const data: pasteboard.PasteData = await clip.getData();
|
const data: pasteboard.PasteData = await clip.getData();
|
||||||
const txt: string = data.getPrimaryText();
|
const txt: string = data.getPrimaryText();
|
||||||
const out: CapResult = { status: 'ok', output: txt ?? '', error: '' };
|
return okResult(txt ?? '');
|
||||||
return out;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg: string = e instanceof Error ? e.message : String(e);
|
return errResult('剪贴板读取失败,请确认应用在前台并已获得系统授权');
|
||||||
return errResult('clipboardsee failed (需系统剪贴板授权): ' + msg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,10 +172,9 @@ export async function capClipboardsue(text: string): Promise<CapResult> {
|
|||||||
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
|
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
|
||||||
const data: pasteboard.PasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
|
const data: pasteboard.PasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
|
||||||
await clip.setPasteData(data);
|
await clip.setPasteData(data);
|
||||||
return okResult('written ' + text.length + ' chars');
|
return okResult('clipboard written');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg: string = e instanceof Error ? e.message : String(e);
|
return errResult('剪贴板写入失败,请保持应用在前台后重试');
|
||||||
return errResult('clipboardsue failed: ' + msg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,13 +184,11 @@ class TtsSession {
|
|||||||
private engine: textToSpeech.TextToSpeechEngine | null = null;
|
private engine: textToSpeech.TextToSpeechEngine | null = null;
|
||||||
|
|
||||||
async speak(text: string): Promise<CapResult> {
|
async speak(text: string): Promise<CapResult> {
|
||||||
|
if (text.length > 4000) {
|
||||||
|
return errResult('朗读内容过长,请缩短到 4000 字以内');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (this.engine === null) {
|
if (this.engine === null) {
|
||||||
const extra: Record<string, Object> = {
|
|
||||||
'style': 'interaction-broadcast',
|
|
||||||
'locate': 'CN',
|
|
||||||
'name': 'EngineName',
|
|
||||||
};
|
|
||||||
const params: textToSpeech.CreateEngineParams = {
|
const params: textToSpeech.CreateEngineParams = {
|
||||||
language: 'zh-CN',
|
language: 'zh-CN',
|
||||||
person: 0,
|
person: 0,
|
||||||
@ -133,8 +203,7 @@ class TtsSession {
|
|||||||
this.engine.speak(text, sp);
|
this.engine.speak(text, sp);
|
||||||
return okResult('speaking');
|
return okResult('speaking');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg: string = e instanceof Error ? e.message : String(e);
|
return errResult('语音服务暂时不可用,请稍后重试');
|
||||||
return errResult('speakeruse failed: ' + msg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -156,20 +225,49 @@ export async function capSpeakerUse(text: string): Promise<CapResult> {
|
|||||||
return ttsSession.speak(text);
|
return ttsSession.speak(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== deviceinfo =====
|
export function shutdownSpeakerUse(): void {
|
||||||
|
ttsSession.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
export function capDeviceInfo(): CapResult {
|
// ===== status / deviceinfo =====
|
||||||
const lines: string[] = [];
|
|
||||||
lines.push('brand=' + deviceInfo.brand);
|
export function capStatus(deviceId: string): CapResult {
|
||||||
lines.push('manufacturer=' + deviceInfo.manufacture);
|
const payload: DeviceStatusPayload = {
|
||||||
lines.push('model=' + deviceInfo.productModel);
|
device_id: deviceId,
|
||||||
lines.push('series=' + deviceInfo.productSeries);
|
status: 'online',
|
||||||
lines.push('osFullName=' + deviceInfo.osFullName);
|
hostname: 'ohos-phone',
|
||||||
lines.push('sdkApiVersion=' + deviceInfo.sdkApiVersion.toString());
|
platform: 'OpenHarmony',
|
||||||
lines.push('securityPatch=' + deviceInfo.securityPatchTag);
|
arch: deviceInfo.abiList,
|
||||||
lines.push('abiList=' + deviceInfo.abiList);
|
uptime: Math.floor((Date.now() - APP_STARTED_AT) / 1000),
|
||||||
lines.push('deviceType=' + deviceInfo.deviceType);
|
};
|
||||||
return okResult(lines.join('\n'));
|
return okResult(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function capDeviceInfo(deviceId: string, deviceName: string): CapResult {
|
||||||
|
const details: DeviceDetails = {
|
||||||
|
hostname: 'ohos-phone',
|
||||||
|
platform: 'OpenHarmony',
|
||||||
|
arch: deviceInfo.abiList,
|
||||||
|
os_release: deviceInfo.osFullName,
|
||||||
|
version: '1.1.1',
|
||||||
|
cpus: 0,
|
||||||
|
brand: deviceInfo.brand,
|
||||||
|
manufacturer: deviceInfo.manufacture,
|
||||||
|
model: deviceInfo.productModel,
|
||||||
|
series: deviceInfo.productSeries,
|
||||||
|
sdk_api_version: deviceInfo.sdkApiVersion,
|
||||||
|
security_patch: deviceInfo.securityPatchTag,
|
||||||
|
abi_list: deviceInfo.abiList,
|
||||||
|
device_type: deviceInfo.deviceType,
|
||||||
|
};
|
||||||
|
const payload: DeviceInfoPayload = {
|
||||||
|
device_id: deviceId,
|
||||||
|
name: deviceName,
|
||||||
|
kind: 'ohos-phone',
|
||||||
|
caps: LOCAL_DEVICE_CAPS,
|
||||||
|
info: details,
|
||||||
|
};
|
||||||
|
return okResult(JSON.stringify(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== screensue 内容解析 =====
|
// ===== screensue 内容解析 =====
|
||||||
@ -182,14 +280,18 @@ export interface ScreensuePayload {
|
|||||||
|
|
||||||
export function parseScreensue(rawArgs: string): ScreensuePayload {
|
export function parseScreensue(rawArgs: string): ScreensuePayload {
|
||||||
const p: ScreensuePayload = { duration: 5, content: '' };
|
const p: ScreensuePayload = { duration: 5, content: '' };
|
||||||
let rest: string = rawArgs.trim();
|
const leadingSpaces: RegExp = new RegExp('^\\s+');
|
||||||
const tokens: string[] = rest.split(/\s+/);
|
const firstSpace: RegExp = new RegExp('\\s');
|
||||||
if (tokens.length > 1 && /^\d+$/.test(tokens[0])) {
|
let rest: string = rawArgs.replace(leadingSpaces, '');
|
||||||
p.duration = parseInt(tokens[0], 10);
|
const splitAt: number = rest.search(firstSpace);
|
||||||
rest = tokens.slice(1).join(' ');
|
if (splitAt > 0) {
|
||||||
} else {
|
const first: string = rest.substring(0, splitAt);
|
||||||
rest = tokens.join(' ');
|
const digits: RegExp = new RegExp('^\\d+$');
|
||||||
|
if (digits.test(first)) {
|
||||||
|
p.duration = Math.min(parseInt(first, 10), 86400);
|
||||||
|
rest = rest.substring(splitAt).replace(leadingSpaces, '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
p.content = rest.trim();
|
p.content = rest;
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { deviceBridge, CmdReply } from './DeviceBridge';
|
import { deviceBridge } from './DeviceBridge';
|
||||||
import {
|
import {
|
||||||
CapResult,
|
CapResult,
|
||||||
capScreensee,
|
capScreensee,
|
||||||
@ -6,12 +6,14 @@ import {
|
|||||||
capClipboardsue,
|
capClipboardsue,
|
||||||
capSpeakerUse,
|
capSpeakerUse,
|
||||||
capDeviceInfo,
|
capDeviceInfo,
|
||||||
|
capStatus,
|
||||||
parseScreensue,
|
parseScreensue,
|
||||||
ScreensuePayload,
|
ScreensuePayload,
|
||||||
} from './BridgeCaps';
|
} from './BridgeCaps';
|
||||||
|
import { connStore } from './ConnStore';
|
||||||
import { common } from '@kit.AbilityKit';
|
import { common } from '@kit.AbilityKit';
|
||||||
|
|
||||||
// screensue 展示回调由 UI 层注册(Index 挂全局悬浮层)
|
// screensue 展示回调由根 UI 注册:窄屏整页,宽屏右侧内容栏。
|
||||||
export type ScreensueHandler = (payload: ScreensuePayload) => void;
|
export type ScreensueHandler = (payload: ScreensuePayload) => void;
|
||||||
|
|
||||||
let screensueHandler: ScreensueHandler | null = null;
|
let screensueHandler: ScreensueHandler | null = null;
|
||||||
@ -25,10 +27,23 @@ export function setBridgeAppContext(ctx: common.UIAbilityContext): void {
|
|||||||
appContext = ctx;
|
appContext = ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 解析 homeagent-* 命令:返回能力名与参数串。 */
|
/** 解析裸能力名或过渡期 homeagent-* 命令;参数正文不裁剪,避免改变推送内容。 */
|
||||||
function splitCapability(command: string): string[] {
|
function splitCapability(command: string): string[] {
|
||||||
const cmd: string = command.trim();
|
let start: number = 0;
|
||||||
const idx: number = cmd.indexOf(' ');
|
while (start < command.length && isCommandSpace(command.charAt(start))) {
|
||||||
|
start = start + 1;
|
||||||
|
}
|
||||||
|
let cmd: string = command.substring(start);
|
||||||
|
if (cmd.startsWith('homeagent-')) {
|
||||||
|
cmd = cmd.substring('homeagent-'.length);
|
||||||
|
}
|
||||||
|
let idx: number = -1;
|
||||||
|
for (let i: number = 0; i < cmd.length; i++) {
|
||||||
|
if (isCommandSpace(cmd.charAt(i))) {
|
||||||
|
idx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (idx < 0) {
|
if (idx < 0) {
|
||||||
return [cmd];
|
return [cmd];
|
||||||
}
|
}
|
||||||
@ -36,51 +51,73 @@ function splitCapability(command: string): string[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCommandSpace(ch: string): boolean {
|
||||||
|
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasArgs(args: string): boolean {
|
||||||
|
return args.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
async function executeCommand(reqId: string, command: string): Promise<CapResult> {
|
async function executeCommand(reqId: string, command: string): Promise<CapResult> {
|
||||||
const parts: string[] = splitCapability(command);
|
const parts: string[] = splitCapability(command);
|
||||||
const name: string = parts[0];
|
const name: string = parts[0];
|
||||||
const args: string = parts.length > 1 ? parts[1] : '';
|
const args: string = parts.length > 1 ? parts[1] : '';
|
||||||
|
|
||||||
// screensee 截屏回传(data URL 走文本结果,服务端兼容)
|
|
||||||
if (name === 'screensee') {
|
if (name === 'screensee') {
|
||||||
|
if (hasArgs(args)) {
|
||||||
|
return errRes('screensee 不接受额外参数');
|
||||||
|
}
|
||||||
return capScreensee();
|
return capScreensee();
|
||||||
}
|
}
|
||||||
if (name === 'screensue') {
|
if (name === 'screensue') {
|
||||||
|
if (!hasArgs(args)) {
|
||||||
|
return errRes('screensue 需要展示内容');
|
||||||
|
}
|
||||||
const payload: ScreensuePayload = parseScreensue(args);
|
const payload: ScreensuePayload = parseScreensue(args);
|
||||||
|
if (payload.content.length === 0) {
|
||||||
|
return errRes('screensue 需要展示内容');
|
||||||
|
}
|
||||||
if (screensueHandler !== null) {
|
if (screensueHandler !== null) {
|
||||||
screensueHandler(payload);
|
screensueHandler(payload);
|
||||||
return okRes('shown');
|
return okRes('内容已显示');
|
||||||
}
|
}
|
||||||
return errRes('screensue: display layer not ready');
|
return errRes('展示界面尚未就绪,请保持应用在前台后重试');
|
||||||
}
|
}
|
||||||
if (name === 'clipboardsee') {
|
if (name === 'clipboardsee') {
|
||||||
|
if (hasArgs(args)) {
|
||||||
|
return errRes('clipboardsee 不接受额外参数');
|
||||||
|
}
|
||||||
if (appContext === null) {
|
if (appContext === null) {
|
||||||
return errRes('clipboardsee: app context missing');
|
return errRes('应用界面尚未就绪,请保持应用在前台后重试');
|
||||||
}
|
}
|
||||||
return capClipboardSee(appContext);
|
return capClipboardSee(appContext);
|
||||||
}
|
}
|
||||||
if (name === 'clipboardsue') {
|
if (name === 'clipboardsue') {
|
||||||
if (args.length === 0) {
|
if (!hasArgs(args)) {
|
||||||
return errRes('clipboardsue: empty text');
|
return errRes('clipboardsue 需要写入文字');
|
||||||
}
|
}
|
||||||
return capClipboardsue(args);
|
return capClipboardsue(args);
|
||||||
}
|
}
|
||||||
if (name === 'speakeruse') {
|
if (name === 'speakeruse') {
|
||||||
if (args.length === 0) {
|
if (!hasArgs(args)) {
|
||||||
return errRes('speakeruse: empty text');
|
return errRes('speakeruse 需要朗读文字');
|
||||||
}
|
}
|
||||||
return capSpeakerUse(args);
|
return capSpeakerUse(args);
|
||||||
}
|
}
|
||||||
if (name === 'deviceinfo' || name === 'status') {
|
if (name === 'status') {
|
||||||
return capDeviceInfo();
|
if (hasArgs(args)) {
|
||||||
|
return errRes('status 不接受额外参数');
|
||||||
|
}
|
||||||
|
return capStatus(connStore.getDeviceId());
|
||||||
}
|
}
|
||||||
if (name === 'camerasue') {
|
if (name === 'deviceinfo') {
|
||||||
return errRes('camerasue: camera capture not supported on this build');
|
if (hasArgs(args)) {
|
||||||
|
return errRes('deviceinfo 不接受额外参数');
|
||||||
|
}
|
||||||
|
return capDeviceInfo(connStore.getDeviceId(), connStore.getDeviceName());
|
||||||
}
|
}
|
||||||
if (name === 'computeruse') {
|
return errRes('不支持的本机能力:' + name);
|
||||||
return errRes('computeruse: not applicable to touch-only device');
|
|
||||||
}
|
|
||||||
return errRes('unsupported homeagent capability: ' + name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function okRes(output: string): CapResult {
|
function okRes(output: string): CapResult {
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { webSocket } from '@kit.NetworkKit';
|
import { webSocket } from '@kit.NetworkKit';
|
||||||
import { DeviceInfo } from '../model/Model';
|
|
||||||
import { CapResult } from './BridgeCaps';
|
import { CapResult } from './BridgeCaps';
|
||||||
|
|
||||||
// ===== 协议消息(与 remotedevice 插件对齐)=====
|
// ===== 协议消息(与 remotedevice 插件对齐)=====
|
||||||
@ -33,13 +32,6 @@ interface BindMessage {
|
|||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CmdMessage {
|
|
||||||
op: string;
|
|
||||||
req_id: string;
|
|
||||||
command: string;
|
|
||||||
cmd_type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CmdReply {
|
export interface CmdReply {
|
||||||
op: string; // 'cmd_result'
|
op: string; // 'cmd_result'
|
||||||
req_id: string;
|
req_id: string;
|
||||||
@ -81,14 +73,15 @@ export class DeviceBridgeClient {
|
|||||||
private caps: string[] = [];
|
private caps: string[] = [];
|
||||||
private hostname: string = 'ohos';
|
private hostname: string = 'ohos';
|
||||||
private connected: boolean = false;
|
private connected: boolean = false;
|
||||||
private everConnected: boolean = false;
|
private bound: boolean = false;
|
||||||
private manualClose: boolean = false;
|
private manualClose: boolean = false;
|
||||||
private reconnectTimer: number = -1;
|
private reconnectTimer: number = -1;
|
||||||
|
private connectionGeneration: number = 0;
|
||||||
private cmdHandler: BridgeCmdHandler | null = null;
|
private cmdHandler: BridgeCmdHandler | null = null;
|
||||||
private onStateChange: ((open: boolean) => void) | null = null;
|
private onStateChange: ((open: boolean) => void) | null = null;
|
||||||
|
|
||||||
isConnected(): boolean {
|
isConnected(): boolean {
|
||||||
return this.connected;
|
return this.connected && this.bound;
|
||||||
}
|
}
|
||||||
|
|
||||||
getDeviceId(): string {
|
getDeviceId(): string {
|
||||||
@ -117,37 +110,40 @@ export class DeviceBridgeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async openAndRegister(authorized: boolean): Promise<void> {
|
private async openAndRegister(authorized: boolean): Promise<void> {
|
||||||
// 每次连接使用新的 WebSocket 实例,避免旧实例事件残留
|
|
||||||
try {
|
try {
|
||||||
this.ws.off('open');
|
this.ws.off('open');
|
||||||
this.ws.off('message');
|
this.ws.off('message');
|
||||||
this.ws.off('close');
|
this.ws.off('close');
|
||||||
this.ws.off('error');
|
this.ws.off('error');
|
||||||
|
this.ws.close().catch(() => {
|
||||||
|
// ignore stale socket close failure
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore
|
// ignore stale socket cleanup failure
|
||||||
}
|
}
|
||||||
this.ws = webSocket.createWebSocket();
|
this.connectionGeneration = this.connectionGeneration + 1;
|
||||||
this.bindWsEvents(authorized);
|
const generation: number = this.connectionGeneration;
|
||||||
// 鉴权必须走请求头,不能拼 ?token= :
|
const socket: webSocket.WebSocket = webSocket.createWebSocket();
|
||||||
// 1) webui 的 /api/v1/device/* 反代包在 requireAPI 里,
|
this.ws = socket;
|
||||||
// validAPIKey 只认 X-API-Key 头或 Authorization: Bearer,
|
this.connected = false;
|
||||||
// 查询参数一律视为未授权 → 握手被 401 顶掉,
|
this.bound = false;
|
||||||
// 表现为 NETSTACK 日志 "Lws client connection error HS: ws upgrade unauthorized"。
|
this.bindWsEvents(socket, authorized, generation);
|
||||||
// 2) 反代到 remotedevice 时会自行注入网关的 ws_token;
|
|
||||||
// 如果我们再带 ?token=<webui apiKey>,remotedevice 的 ServeWS
|
|
||||||
// 会拿它和 ws_token 比对并 401。留空反而放行。
|
|
||||||
const opts: webSocket.WebSocketRequestOptions = {
|
const opts: webSocket.WebSocketRequestOptions = {
|
||||||
header: this.authHeader(),
|
header: this.authHeader(),
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await this.ws.connect(this.url, opts);
|
await socket.connect(this.url, opts);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.connected = false;
|
if (generation === this.connectionGeneration && !this.manualClose) {
|
||||||
this.scheduleReconnect();
|
this.connected = false;
|
||||||
|
this.bound = false;
|
||||||
|
this.notifyState(false);
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 握手请求头:X-API-Key + Authorization 双写,兼容不同后端校验实现。 */
|
/** WebUI 用 API key 验证外层连接,并由反代向设备网关注入其内部 token。 */
|
||||||
private authHeader(): Record<string, string> {
|
private authHeader(): Record<string, string> {
|
||||||
const h: Record<string, string> = {};
|
const h: Record<string, string> = {};
|
||||||
if (this.token.length > 0) {
|
if (this.token.length > 0) {
|
||||||
@ -157,38 +153,48 @@ export class DeviceBridgeClient {
|
|||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bindWsEvents(authorized: boolean): void {
|
private bindWsEvents(socket: webSocket.WebSocket, authorized: boolean, generation: number): void {
|
||||||
this.ws.on('open', (err: Error, value: Object) => {
|
socket.on('open', (err: Error, value: Object) => {
|
||||||
|
if (generation !== this.connectionGeneration || this.manualClose) {
|
||||||
|
socket.close().catch(() => {
|
||||||
|
// ignore stale socket close failure
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
this.everConnected = true;
|
this.bound = false;
|
||||||
this.cancelReconnect();
|
|
||||||
this.sendHello(authorized);
|
this.sendHello(authorized);
|
||||||
this.sendBind();
|
this.sendBind();
|
||||||
if (this.onStateChange !== null) {
|
|
||||||
this.onStateChange(true);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
this.ws.on('message', (err: Error, value: string | ArrayBuffer) => {
|
socket.on('message', (err: Error, value: string | ArrayBuffer) => {
|
||||||
if (typeof value === 'string') {
|
if (generation === this.connectionGeneration && typeof value === 'string') {
|
||||||
this.handleTextFrame(value);
|
this.handleTextFrame(value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.ws.on('close', (err: Error, value: webSocket.CloseResult) => {
|
socket.on('close', (err: Error, value: webSocket.CloseResult) => {
|
||||||
this.connected = false;
|
this.handleSocketEnd(generation);
|
||||||
if (this.onStateChange !== null) {
|
|
||||||
this.onStateChange(false);
|
|
||||||
}
|
|
||||||
this.scheduleReconnect();
|
|
||||||
});
|
});
|
||||||
this.ws.on('error', (err: Error) => {
|
socket.on('error', (err: Error) => {
|
||||||
this.connected = false;
|
this.handleSocketEnd(generation);
|
||||||
if (this.onStateChange !== null) {
|
|
||||||
this.onStateChange(false);
|
|
||||||
}
|
|
||||||
this.scheduleReconnect();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleSocketEnd(generation: number): void {
|
||||||
|
if (generation !== this.connectionGeneration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.connected = false;
|
||||||
|
this.bound = false;
|
||||||
|
this.notifyState(false);
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyState(open: boolean): void {
|
||||||
|
if (this.onStateChange !== null) {
|
||||||
|
this.onStateChange(open);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private scheduleReconnect(): void {
|
private scheduleReconnect(): void {
|
||||||
if (this.manualClose || this.reconnectTimer >= 0) {
|
if (this.manualClose || this.reconnectTimer >= 0) {
|
||||||
return;
|
return;
|
||||||
@ -211,18 +217,20 @@ export class DeviceBridgeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 更新本地授权状态并立即重新 hello 同步到服务端。 */
|
/** 更新本地授权状态并在已绑定连接上同步到服务端。 */
|
||||||
updateAuthorized(authorized: boolean): void {
|
updateAuthorized(authorized: boolean): void {
|
||||||
this.lastAuthorized = authorized;
|
this.lastAuthorized = authorized;
|
||||||
if (this.connected) {
|
if (this.connected && this.bound) {
|
||||||
this.sendHello(authorized);
|
this.sendHello(authorized);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
this.manualClose = true;
|
this.manualClose = true;
|
||||||
|
this.connectionGeneration = this.connectionGeneration + 1;
|
||||||
this.cancelReconnect();
|
this.cancelReconnect();
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
|
this.bound = false;
|
||||||
try {
|
try {
|
||||||
this.ws.off('open');
|
this.ws.off('open');
|
||||||
this.ws.off('message');
|
this.ws.off('message');
|
||||||
@ -234,9 +242,7 @@ export class DeviceBridgeClient {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
if (this.onStateChange !== null) {
|
this.notifyState(false);
|
||||||
this.onStateChange(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendHello(authorized: boolean): void {
|
private sendHello(authorized: boolean): void {
|
||||||
@ -246,7 +252,7 @@ export class DeviceBridgeClient {
|
|||||||
platform: 'OpenHarmony',
|
platform: 'OpenHarmony',
|
||||||
arch: '',
|
arch: '',
|
||||||
os_release: '',
|
os_release: '',
|
||||||
version: '1.1.0',
|
version: '1.1.1',
|
||||||
cpus: 0,
|
cpus: 0,
|
||||||
};
|
};
|
||||||
const device: HelloDevice = {
|
const device: HelloDevice = {
|
||||||
@ -280,37 +286,50 @@ export class DeviceBridgeClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const op: string = obj['op'] as string ?? '';
|
const op: string = obj['op'] as string ?? '';
|
||||||
if (op === 'cmd') {
|
if (op === 'bind_ack') {
|
||||||
const reqId: string = obj['req_id'] as string ?? '';
|
const accepted: boolean = obj['ok'] === true;
|
||||||
const command: string = obj['command'] as string ?? '';
|
if (accepted && this.connected && !this.manualClose) {
|
||||||
if (reqId.length === 0 || command.length === 0) {
|
this.bound = true;
|
||||||
return;
|
this.cancelReconnect();
|
||||||
}
|
this.notifyState(true);
|
||||||
if (!this.lastAuthorized) {
|
} else {
|
||||||
this.sendResult(reqId, 'error', '', '设备未授权:请在设备页开启远程控制授权');
|
this.bound = false;
|
||||||
return;
|
this.notifyState(false);
|
||||||
}
|
try {
|
||||||
this.dispatchCommand(reqId, command);
|
this.ws.close().catch(() => {
|
||||||
} else if (op === 'hello_ack' || op === 'bind_ack') {
|
// ignore bind rejection close failure
|
||||||
if (this.onAck !== null) {
|
});
|
||||||
this.onAck(op);
|
} catch (e) {
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (op !== 'cmd' || !this.bound) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reqId: string = obj['req_id'] as string ?? '';
|
||||||
|
const command: string = obj['command'] as string ?? '';
|
||||||
|
if (reqId.length === 0 || command.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.lastAuthorized) {
|
||||||
|
this.sendResult(reqId, 'error', '', '设备未授权:请在设备页开启远程控制授权');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.dispatchCommand(reqId, command);
|
||||||
}
|
}
|
||||||
|
|
||||||
onAck: ((op: string) => void) | null = null;
|
|
||||||
|
|
||||||
private dispatchCommand(reqId: string, command: string): void {
|
private dispatchCommand(reqId: string, command: string): void {
|
||||||
if (this.cmdHandler === null) {
|
if (this.cmdHandler === null) {
|
||||||
this.sendResult(reqId, 'error', '', 'no capability handler registered');
|
this.sendResult(reqId, 'error', '', '本机能力尚未就绪,请保持应用在前台后重试');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const handler: BridgeCmdHandler = this.cmdHandler;
|
const handler: BridgeCmdHandler = this.cmdHandler;
|
||||||
handler(reqId, command).then((res: CapResult) => {
|
handler(reqId, command).then((res: CapResult) => {
|
||||||
this.sendResult(reqId, res.status, res.output, res.error);
|
this.sendResult(reqId, res.status, res.output, res.error);
|
||||||
}).catch((e: Object) => {
|
}).catch((e: Object) => {
|
||||||
const msg: string = e instanceof Error ? e.message : String(e);
|
this.sendResult(reqId, 'error', '', '本机能力执行失败,请稍后重试');
|
||||||
this.sendResult(reqId, 'error', '', msg);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -388,7 +407,3 @@ export class DeviceBridgeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const deviceBridge: DeviceBridgeClient = new DeviceBridgeClient();
|
export const deviceBridge: DeviceBridgeClient = new DeviceBridgeClient();
|
||||||
|
|
||||||
export function parseDevicesPayload(jsonStr: string): DeviceInfo[] {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|||||||
@ -0,0 +1,105 @@
|
|||||||
|
import { common } from '@kit.AbilityKit';
|
||||||
|
import { deviceBridge } from './DeviceBridge';
|
||||||
|
import { installCmdRouter, setBridgeAppContext } from './BridgeRouter';
|
||||||
|
import { LOCAL_DEVICE_CAPS, shutdownSpeakerUse } from './BridgeCaps';
|
||||||
|
import { connStore } from './ConnStore';
|
||||||
|
import { ConnectionConfig } from '../model/Model';
|
||||||
|
|
||||||
|
export { LOCAL_DEVICE_CAPS } from './BridgeCaps';
|
||||||
|
|
||||||
|
let bridgeStarting: boolean = false;
|
||||||
|
let foregroundActive: boolean = false;
|
||||||
|
let rootUIReady: boolean = false;
|
||||||
|
let bridgeGeneration: number = 0;
|
||||||
|
let stateTrackingReady: boolean = false;
|
||||||
|
|
||||||
|
function ensureBridgeStateTracking(): void {
|
||||||
|
if (stateTrackingReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stateTrackingReady = true;
|
||||||
|
AppStorage.setOrCreate<boolean>('deviceBridgeConnected', false);
|
||||||
|
deviceBridge.setStateListener((open: boolean) => {
|
||||||
|
AppStorage.set<boolean>('deviceBridgeConnected', open);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把当前后端 HTTP 地址转换为同源设备桥 WebSocket 地址。 */
|
||||||
|
export function deviceGatewayUrl(base: string): string {
|
||||||
|
let trimmed: string = base.trim();
|
||||||
|
while (trimmed.length > 0 && trimmed.charAt(trimmed.length - 1) === '/') {
|
||||||
|
trimmed = trimmed.substring(0, trimmed.length - 1);
|
||||||
|
}
|
||||||
|
let scheme: string = 'ws://';
|
||||||
|
let rest: string = trimmed;
|
||||||
|
if (trimmed.startsWith('https://')) {
|
||||||
|
scheme = 'wss://';
|
||||||
|
rest = trimmed.substring('https://'.length);
|
||||||
|
} else if (trimmed.startsWith('http://')) {
|
||||||
|
rest = trimmed.substring('http://'.length);
|
||||||
|
} else if (trimmed.startsWith('wss://')) {
|
||||||
|
scheme = 'wss://';
|
||||||
|
rest = trimmed.substring('wss://'.length);
|
||||||
|
} else if (trimmed.startsWith('ws://')) {
|
||||||
|
rest = trimmed.substring('ws://'.length);
|
||||||
|
}
|
||||||
|
return scheme + rest + '/api/v1/device/ws';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用进入前台后建立全局设备桥。它不再依赖用户先打开“设备”Tab,
|
||||||
|
* 因而 screensue、clipboardsee 等前台能力从主页面加载后即可接收。
|
||||||
|
*/
|
||||||
|
export async function startForegroundBridge(context: common.UIAbilityContext): Promise<void> {
|
||||||
|
foregroundActive = true;
|
||||||
|
setBridgeAppContext(context);
|
||||||
|
installCmdRouter();
|
||||||
|
ensureBridgeStateTracking();
|
||||||
|
if (!rootUIReady || deviceBridge.isConnected() || bridgeStarting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
|
||||||
|
if (cur === null || cur.url.length === 0 || cur.apiKey.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bridgeStarting = true;
|
||||||
|
bridgeGeneration = bridgeGeneration + 1;
|
||||||
|
const generation: number = bridgeGeneration;
|
||||||
|
const deviceId: string = connStore.ensureDeviceId();
|
||||||
|
try {
|
||||||
|
await deviceBridge.connect(
|
||||||
|
deviceGatewayUrl(cur.url), cur.apiKey, deviceId,
|
||||||
|
LOCAL_DEVICE_CAPS, 'ohos-phone', connStore.getDeviceAuth(), connStore.getDeviceName());
|
||||||
|
if (!foregroundActive || generation !== bridgeGeneration) {
|
||||||
|
deviceBridge.disconnect();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// DeviceBridge 自己会安排重连;前台启动不弹技术错误打扰用户。
|
||||||
|
}
|
||||||
|
if (generation === bridgeGeneration) {
|
||||||
|
bridgeStarting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根页面挂载完成后才连接,避免首条 screensue 到达时展示层尚未注册。 */
|
||||||
|
export function markForegroundBridgeUIReady(context: common.UIAbilityContext): void {
|
||||||
|
rootUIReady = true;
|
||||||
|
startForegroundBridge(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后台不接受需要前台 UI/剪贴板授权的命令。 */
|
||||||
|
export function stopForegroundBridge(): void {
|
||||||
|
foregroundActive = false;
|
||||||
|
bridgeGeneration = bridgeGeneration + 1;
|
||||||
|
bridgeStarting = false;
|
||||||
|
shutdownSpeakerUse();
|
||||||
|
deviceBridge.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 连接配置切换或修改后立即让设备桥使用新地址和 Token。 */
|
||||||
|
export async function restartForegroundBridge(context: common.UIAbilityContext): Promise<void> {
|
||||||
|
bridgeGeneration = bridgeGeneration + 1;
|
||||||
|
bridgeStarting = false;
|
||||||
|
deviceBridge.disconnect();
|
||||||
|
await startForegroundBridge(context);
|
||||||
|
}
|
||||||
@ -67,8 +67,11 @@ class StatusStore {
|
|||||||
this.fail(noConnectionMessage());
|
this.fail(noConnectionMessage());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
AppStorage.setOrCreate<boolean>(K_LOADING, true);
|
// setOrCreate 只负责首次建键,键已存在时不会覆盖旧值。
|
||||||
AppStorage.setOrCreate<string>(K_ERR, '');
|
// init() 已把所有键种好,刷新阶段必须用 set,否则摘要卡会永远停在
|
||||||
|
// 版本 "-"、插件 0 的初始状态。
|
||||||
|
AppStorage.set<boolean>(K_LOADING, true);
|
||||||
|
AppStorage.set<string>(K_ERR, '');
|
||||||
try {
|
try {
|
||||||
const resp = await apiClient.getWithTimeout('/status', 8000);
|
const resp = await apiClient.getWithTimeout('/status', 8000);
|
||||||
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||||
@ -79,10 +82,10 @@ class StatusStore {
|
|||||||
const agents: number = obj['agents'] as number ?? 0;
|
const agents: number = obj['agents'] as number ?? 0;
|
||||||
const startedAt: string = obj['startedAt'] as string ?? '';
|
const startedAt: string = obj['startedAt'] as string ?? '';
|
||||||
|
|
||||||
AppStorage.setOrCreate<boolean>(K_UP, true);
|
AppStorage.set<boolean>(K_UP, true);
|
||||||
AppStorage.setOrCreate<string>(K_VERSION, version);
|
AppStorage.set<string>(K_VERSION, version);
|
||||||
AppStorage.setOrCreate<number>(K_AGENTS, agents);
|
AppStorage.set<number>(K_AGENTS, agents);
|
||||||
AppStorage.setOrCreate<string>(K_STARTED, startedAt);
|
AppStorage.set<string>(K_STARTED, startedAt);
|
||||||
|
|
||||||
const systemFields: StatField[] = [
|
const systemFields: StatField[] = [
|
||||||
{ label: '版本', value: version },
|
{ label: '版本', value: version },
|
||||||
@ -103,7 +106,7 @@ class StatusStore {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.fail(userMessage('status.refresh', e));
|
this.fail(userMessage('status.refresh', e));
|
||||||
}
|
}
|
||||||
AppStorage.setOrCreate<boolean>(K_LOADING, false);
|
AppStorage.set<boolean>(K_LOADING, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /kernel 可能不存在(旧后端),失败不影响 /status 已取到的部分 */
|
/** /kernel 可能不存在(旧后端),失败不影响 /status 已取到的部分 */
|
||||||
@ -119,8 +122,8 @@ class StatusStore {
|
|||||||
const pluginCount: number = pluginsArr !== undefined ? pluginsArr.length : 0;
|
const pluginCount: number = pluginsArr !== undefined ? pluginsArr.length : 0;
|
||||||
const toolCount: number = toolsArr !== undefined ? toolsArr.length : 0;
|
const toolCount: number = toolsArr !== undefined ? toolsArr.length : 0;
|
||||||
|
|
||||||
AppStorage.setOrCreate<number>(K_PLUGINS, pluginCount);
|
AppStorage.set<number>(K_PLUGINS, pluginCount);
|
||||||
AppStorage.setOrCreate<number>(K_TOOLS, toolCount);
|
AppStorage.set<number>(K_TOOLS, toolCount);
|
||||||
|
|
||||||
const kernelFields: StatField[] = [
|
const kernelFields: StatField[] = [
|
||||||
{ label: 'Agent ID', value: agentId },
|
{ label: 'Agent ID', value: agentId },
|
||||||
@ -219,9 +222,9 @@ class StatusStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fail(msg: string): void {
|
private fail(msg: string): void {
|
||||||
AppStorage.setOrCreate<string>(K_ERR, msg);
|
AppStorage.set<string>(K_ERR, msg);
|
||||||
AppStorage.setOrCreate<boolean>(K_UP, false);
|
AppStorage.set<boolean>(K_UP, false);
|
||||||
AppStorage.setOrCreate<boolean>(K_LOADING, false);
|
AppStorage.set<boolean>(K_LOADING, false);
|
||||||
this.groups = [];
|
this.groups = [];
|
||||||
this.bump();
|
this.bump();
|
||||||
}
|
}
|
||||||
@ -229,7 +232,7 @@ class StatusStore {
|
|||||||
/** 明细数组不进 AppStorage,用一个自增版本号触发订阅组件重取 */
|
/** 明细数组不进 AppStorage,用一个自增版本号触发订阅组件重取 */
|
||||||
private bump(): void {
|
private bump(): void {
|
||||||
const cur: number = AppStorage.get<number>(K_REV) ?? 0;
|
const cur: number = AppStorage.get<number>(K_REV) ?? 0;
|
||||||
AppStorage.setOrCreate<number>(K_REV, cur + 1);
|
AppStorage.set<number>(K_REV, cur + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,103 @@
|
|||||||
|
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_NORMAL } from '../common/Constants';
|
||||||
|
import { GradientBackground } from './GradientBackground';
|
||||||
|
import { PageTopBar } from './PageTopBar';
|
||||||
|
import { MotionBase } from './MotionBase';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* agent 主动推送的前台内容页。
|
||||||
|
*
|
||||||
|
* 调用方负责决定页面宽度:窄屏占满窗口,宽屏只占右侧内容栏,
|
||||||
|
* 从而让左侧一级页面和主导航保持可见、可操作。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
export struct ScreensuePage {
|
||||||
|
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||||
|
@Prop pushedText: string = '';
|
||||||
|
@Prop countdown: number = 0;
|
||||||
|
onClose: () => void = () => {
|
||||||
|
};
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Stack({ alignContent: Alignment.Bottom }) {
|
||||||
|
GradientBackground()
|
||||||
|
|
||||||
|
Scroll() {
|
||||||
|
Column({ space: 14 }) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Circle({ width: 8, height: 8 })
|
||||||
|
.fill(this.palette().accent)
|
||||||
|
Text('agent 推送')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor(this.palette().textSecondary)
|
||||||
|
Blank()
|
||||||
|
if (this.countdown > 0) {
|
||||||
|
Text(this.countdown.toString() + 's')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor(this.palette().textMuted)
|
||||||
|
} else {
|
||||||
|
Text('常驻')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor(this.palette().textMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Column() {
|
||||||
|
Text(this.pushedText)
|
||||||
|
.fontSize(16)
|
||||||
|
.lineHeight(25)
|
||||||
|
.fontColor(this.palette().textPrimary)
|
||||||
|
.width('100%')
|
||||||
|
.textAlign(TextAlign.Start)
|
||||||
|
.copyOption(CopyOptions.LocalDevice)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(18)
|
||||||
|
.borderRadius(18)
|
||||||
|
.backgroundColor(this.palette().bgCard)
|
||||||
|
.border({ width: 1, color: this.palette().glassBorder })
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 18, right: 18, top: 82, bottom: 96 })
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.align(Alignment.Top)
|
||||||
|
|
||||||
|
PageTopBar({ title: '推送内容' })
|
||||||
|
|
||||||
|
Row() {
|
||||||
|
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||||||
|
Button('关闭')
|
||||||
|
.height(42)
|
||||||
|
.padding({ left: 22, right: 22 })
|
||||||
|
.fontSize(14)
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor(Color.White)
|
||||||
|
.backgroundColor(this.palette().accent)
|
||||||
|
.borderRadius(21)
|
||||||
|
.onClick(() => {
|
||||||
|
this.onClose();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 18, right: 18, bottom: 22 })
|
||||||
|
.justifyContent(FlexAlign.End)
|
||||||
|
.transition(TransitionEffect.OPACITY
|
||||||
|
.combine(TransitionEffect.translate({ y: 18 }))
|
||||||
|
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.backgroundColor(this.palette().bgPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
private palette(): ThemePalette {
|
||||||
|
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,7 +13,11 @@ import { GradientBackground } from './GradientBackground';
|
|||||||
export const KEY_SUBPAGE_OPEN: string = 'subPageOpen';
|
export const KEY_SUBPAGE_OPEN: string = 'subPageOpen';
|
||||||
|
|
||||||
export function markSubPageOpen(open: boolean): void {
|
export function markSubPageOpen(open: boolean): void {
|
||||||
AppStorage.setOrCreate<boolean>(KEY_SUBPAGE_OPEN, open);
|
if (AppStorage.has(KEY_SUBPAGE_OPEN)) {
|
||||||
|
AppStorage.set<boolean>(KEY_SUBPAGE_OPEN, open);
|
||||||
|
} else {
|
||||||
|
AppStorage.setOrCreate<boolean>(KEY_SUBPAGE_OPEN, open);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** pushPathByName 的参数载体:ArkTS 不允许把 string 断言成 object */
|
/** pushPathByName 的参数载体:ArkTS 不允许把 string 断言成 object */
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { BusinessError } from '@kit.BasicServicesKit';
|
|||||||
import { connStore } from '../common/ConnStore';
|
import { connStore } from '../common/ConnStore';
|
||||||
import { apiClient } from '../common/ApiClient';
|
import { apiClient } from '../common/ApiClient';
|
||||||
import { themeIsDark, seedTheme, seedSystemIsDark, resolveIsDark, applyThemeMode } from '../common/Constants';
|
import { themeIsDark, seedTheme, seedSystemIsDark, resolveIsDark, applyThemeMode } from '../common/Constants';
|
||||||
|
import { startForegroundBridge, stopForegroundBridge } from '../common/DeviceBridgeSession';
|
||||||
|
|
||||||
/** Read the persisted theme mode ('system'|'dark'|'light'), defaulting to 'system'. */
|
/** Read the persisted theme mode ('system'|'dark'|'light'), defaulting to 'system'. */
|
||||||
function storedThemeMode(): string {
|
function storedThemeMode(): string {
|
||||||
@ -31,6 +32,7 @@ export default class EntryAbility extends UIAbility {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onDestroy(): void {
|
onDestroy(): void {
|
||||||
|
stopForegroundBridge();
|
||||||
console.info('[HomeAgent] ability onDestroy');
|
console.info('[HomeAgent] ability onDestroy');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,6 +88,7 @@ export default class EntryAbility extends UIAbility {
|
|||||||
apiClient.setConnection(cur);
|
apiClient.setConnection(cur);
|
||||||
}
|
}
|
||||||
this.reapplyStoredTheme();
|
this.reapplyStoredTheme();
|
||||||
|
startForegroundBridge(this.context);
|
||||||
startUI();
|
startUI();
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
startUI();
|
startUI();
|
||||||
@ -107,6 +110,7 @@ export default class EntryAbility extends UIAbility {
|
|||||||
// init 之后持久化的主题模式才可读,这里按存量设置重新解析并刷新系统栏
|
// init 之后持久化的主题模式才可读,这里按存量设置重新解析并刷新系统栏
|
||||||
this.reapplyStoredTheme();
|
this.reapplyStoredTheme();
|
||||||
this.applySystemBar();
|
this.applySystemBar();
|
||||||
|
startForegroundBridge(this.context);
|
||||||
startUI();
|
startUI();
|
||||||
}).catch((e: Error) => {
|
}).catch((e: Error) => {
|
||||||
console.error('[HomeAgent] connStore init failed: ' + e.message);
|
console.error('[HomeAgent] connStore init failed: ' + e.message);
|
||||||
@ -129,10 +133,12 @@ export default class EntryAbility extends UIAbility {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onForeground(): void {
|
onForeground(): void {
|
||||||
|
startForegroundBridge(this.context);
|
||||||
console.info('[HomeAgent] ability onForeground');
|
console.info('[HomeAgent] ability onForeground');
|
||||||
}
|
}
|
||||||
|
|
||||||
onBackground(): void {
|
onBackground(): void {
|
||||||
|
stopForegroundBridge();
|
||||||
console.info('[HomeAgent] ability onBackground');
|
console.info('[HomeAgent] ability onBackground');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,27 +1,14 @@
|
|||||||
import { deviceBridge } from '../common/DeviceBridge';
|
import { deviceBridge } from '../common/DeviceBridge';
|
||||||
import { installCmdRouter, registerScreensueHandler, setBridgeAppContext } from '../common/BridgeRouter';
|
|
||||||
import { connStore } from '../common/ConnStore';
|
import { connStore } from '../common/ConnStore';
|
||||||
import { apiClient } from '../common/ApiClient';
|
import { apiClient } from '../common/ApiClient';
|
||||||
import { noConnectionMessage } from '../common/UserError';
|
|
||||||
import { handleNavOnScroll } from '../common/NavBarController';
|
import { handleNavOnScroll } from '../common/NavBarController';
|
||||||
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
||||||
import { DeviceInfo } from '../model/Model';
|
import { DeviceInfo } from '../model/Model';
|
||||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||||
import { MotionBase } from '../components/MotionBase';
|
import { MotionBase } from '../components/MotionBase';
|
||||||
import { common } from '@kit.AbilityKit';
|
|
||||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
|
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
|
||||||
import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
|
import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||||
|
import { LOCAL_DEVICE_CAPS, deviceGatewayUrl } from '../common/DeviceBridgeSession';
|
||||||
// 本机声明的能力(与 BridgeRouter 支持的命令一一对应)
|
|
||||||
const LOCAL_CAPS: string[] = [
|
|
||||||
'status',
|
|
||||||
'deviceinfo',
|
|
||||||
'screensee',
|
|
||||||
'screensue',
|
|
||||||
'clipboardsee',
|
|
||||||
'clipboardsue',
|
|
||||||
'speakeruse',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** 二级页面标识 */
|
/** 二级页面标识 */
|
||||||
const SUB_NONE: string = '';
|
const SUB_NONE: string = '';
|
||||||
@ -39,19 +26,17 @@ export struct DevicePage {
|
|||||||
@StorageProp('isWideScreen') private isWide: boolean = false;
|
@StorageProp('isWideScreen') private isWide: boolean = false;
|
||||||
/** 当前右栏展示的二级页面 id,用于宽屏下高亮左侧入口行 */
|
/** 当前右栏展示的二级页面 id,用于宽屏下高亮左侧入口行 */
|
||||||
@State activeSub: string = SUB_NONE;
|
@State activeSub: string = SUB_NONE;
|
||||||
@State bridgeConnected: boolean = false;
|
@StorageProp('deviceBridgeConnected') private bridgeConnected: boolean = false;
|
||||||
@State bridgeUrl: string = '';
|
@State bridgeUrl: string = '';
|
||||||
@State bridgeToken: string = '';
|
@State bridgeToken: string = '';
|
||||||
@State deviceId: string = '';
|
@State deviceId: string = '';
|
||||||
@State authorized: boolean = false;
|
@State authorized: boolean = false;
|
||||||
@State devices: DeviceInfo[] = [];
|
@State devices: DeviceInfo[] = [];
|
||||||
@State loadingDevices: boolean = false;
|
@State loadingDevices: boolean = false;
|
||||||
@State lastError: string = '';
|
|
||||||
@State toastMsg: string = '';
|
@State toastMsg: string = '';
|
||||||
@State toastIsError: boolean = false;
|
@State toastIsError: boolean = false;
|
||||||
/** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
|
/** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
|
||||||
private navStack: NavPathStack = new NavPathStack();
|
private navStack: NavPathStack = new NavPathStack();
|
||||||
private autoConnectTried: boolean = false;
|
|
||||||
|
|
||||||
aboutToAppear(): void {
|
aboutToAppear(): void {
|
||||||
this.deviceId = deviceBridge.getDeviceId();
|
this.deviceId = deviceBridge.getDeviceId();
|
||||||
@ -70,25 +55,9 @@ export struct DevicePage {
|
|||||||
// Gateway URL derives from current connection
|
// Gateway URL derives from current connection
|
||||||
const cur = connStore.getCurrentConnection();
|
const cur = connStore.getCurrentConnection();
|
||||||
if (cur !== null) {
|
if (cur !== null) {
|
||||||
this.bridgeUrl = this.gatewayUrlOf(cur.url);
|
this.bridgeUrl = deviceGatewayUrl(cur.url);
|
||||||
this.bridgeToken = cur.apiKey;
|
this.bridgeToken = cur.apiKey;
|
||||||
}
|
}
|
||||||
installCmdRouter();
|
|
||||||
try {
|
|
||||||
setBridgeAppContext(getContext(this) as common.UIAbilityContext);
|
|
||||||
} catch (e) {
|
|
||||||
// ignore context errors
|
|
||||||
}
|
|
||||||
deviceBridge.setStateListener((open: boolean) => {
|
|
||||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
|
||||||
this.bridgeConnected = open;
|
|
||||||
});
|
|
||||||
if (open) {
|
|
||||||
this.lastError = '';
|
|
||||||
this.showToast('设备网关已连接', false);
|
|
||||||
this.refreshDevices();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.refreshDevices();
|
this.refreshDevices();
|
||||||
// 登记导航栈:返回手势由 Index.onBackPress 按当前 Tab 精确派发过来
|
// 登记导航栈:返回手势由 Index.onBackPress 按当前 Tab 精确派发过来
|
||||||
registerNavStack(2, this.navStack, () => {
|
registerNavStack(2, this.navStack, () => {
|
||||||
@ -104,37 +73,6 @@ export struct DevicePage {
|
|||||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 把后端 HTTP 地址转成设备桥的 WebSocket 地址。
|
|
||||||
*
|
|
||||||
* 关键:@ohos.net.webSocket 只接受 ws:// / wss:// 协议头,
|
|
||||||
* 直接把 http:// 传进 connect() 会在 native 层报
|
|
||||||
* "protocol failed" + "ParseUrl failed"(NETSTACK websocket_exec.cpp),
|
|
||||||
* 表现为设备通道永远连不上。所以这里必须做协议替换。
|
|
||||||
*/
|
|
||||||
private gatewayUrlOf(base: string): string {
|
|
||||||
let trimmed: string = base.trim();
|
|
||||||
while (trimmed.length > 0 && trimmed.charAt(trimmed.length - 1) === '/') {
|
|
||||||
trimmed = trimmed.substring(0, trimmed.length - 1);
|
|
||||||
}
|
|
||||||
let scheme: string = 'ws://';
|
|
||||||
let rest: string = trimmed;
|
|
||||||
if (trimmed.startsWith('https://')) {
|
|
||||||
scheme = 'wss://';
|
|
||||||
rest = trimmed.substring('https://'.length);
|
|
||||||
} else if (trimmed.startsWith('http://')) {
|
|
||||||
scheme = 'ws://';
|
|
||||||
rest = trimmed.substring('http://'.length);
|
|
||||||
} else if (trimmed.startsWith('wss://')) {
|
|
||||||
scheme = 'wss://';
|
|
||||||
rest = trimmed.substring('wss://'.length);
|
|
||||||
} else if (trimmed.startsWith('ws://')) {
|
|
||||||
scheme = 'ws://';
|
|
||||||
rest = trimmed.substring('ws://'.length);
|
|
||||||
}
|
|
||||||
return scheme + rest + '/api/v1/device/ws';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开二级页面。
|
* 打开二级页面。
|
||||||
*
|
*
|
||||||
@ -170,45 +108,9 @@ export struct DevicePage {
|
|||||||
// ignore persist failure
|
// ignore persist failure
|
||||||
}
|
}
|
||||||
deviceBridge.updateAuthorized(on);
|
deviceBridge.updateAuthorized(on);
|
||||||
if (!this.bridgeConnected) {
|
|
||||||
this.connectBridge();
|
|
||||||
}
|
|
||||||
this.showToast(on ? '已授权,agent 可下发能力命令' : '已取消授权', false);
|
this.showToast(on ? '已授权,agent 可下发能力命令' : '已取消授权', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================== gateway connection =====================
|
|
||||||
|
|
||||||
private async connectBridge(): Promise<void> {
|
|
||||||
if (this.bridgeUrl.length === 0 || this.bridgeToken.length === 0) {
|
|
||||||
this.lastError = noConnectionMessage();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.lastError = '';
|
|
||||||
if (this.deviceId.length === 0) {
|
|
||||||
this.deviceId = 'ohos-' + Date.now().toString(36);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
connStore.saveDeviceId(this.deviceId);
|
|
||||||
} catch (e) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
const name: string = 'HomeAgent OHOS';
|
|
||||||
await deviceBridge.connect(
|
|
||||||
this.bridgeUrl, this.bridgeToken, this.deviceId,
|
|
||||||
LOCAL_CAPS, 'ohos-phone', this.authorized, name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 首次进入自动尝试连接(静默,失败不打扰)。 */
|
|
||||||
private maybeAutoConnect(): void {
|
|
||||||
if (this.autoConnectTried || this.bridgeConnected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.autoConnectTried = true;
|
|
||||||
if (this.bridgeUrl.length > 0 && this.bridgeToken.length > 0) {
|
|
||||||
this.connectBridge();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private showToast(msg: string, isError: boolean): void {
|
private showToast(msg: string, isError: boolean): void {
|
||||||
// 颜色标记必须在动画闭包外先落定,否则第一帧用的还是上一条 toast 的配色
|
// 颜色标记必须在动画闭包外先落定,否则第一帧用的还是上一条 toast 的配色
|
||||||
this.toastIsError = isError;
|
this.toastIsError = isError;
|
||||||
@ -287,9 +189,6 @@ export struct DevicePage {
|
|||||||
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
|
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
|
||||||
handleNavOnScroll(state);
|
handleNavOnScroll(state);
|
||||||
})
|
})
|
||||||
.onAppear(() => {
|
|
||||||
this.maybeAutoConnect();
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.height('100%')
|
.height('100%')
|
||||||
@ -479,7 +378,7 @@ export struct DevicePage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private capsCount(): number {
|
private capsCount(): number {
|
||||||
return LOCAL_CAPS.length;
|
return LOCAL_DEVICE_CAPS.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================== 二级:本机设备 =====================
|
// ===================== 二级:本机设备 =====================
|
||||||
@ -542,7 +441,7 @@ export struct DevicePage {
|
|||||||
.margin({ bottom: 10 })
|
.margin({ bottom: 10 })
|
||||||
|
|
||||||
Flex({ wrap: FlexWrap.Wrap }) {
|
Flex({ wrap: FlexWrap.Wrap }) {
|
||||||
ForEach(LOCAL_CAPS, (cap: string) => {
|
ForEach(LOCAL_DEVICE_CAPS, (cap: string) => {
|
||||||
Text(cap)
|
Text(cap)
|
||||||
.fontSize(11)
|
.fontSize(11)
|
||||||
.fontColor(this.palette().accent)
|
.fontColor(this.palette().accent)
|
||||||
@ -567,39 +466,8 @@ export struct DevicePage {
|
|||||||
this.KvRow('状态', this.bridgeConnected ? '已连接' : '未连接')
|
this.KvRow('状态', this.bridgeConnected ? '已连接' : '未连接')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.lastError.length > 0) {
|
|
||||||
Text(this.lastError)
|
|
||||||
.fontSize(12)
|
|
||||||
.fontColor('#E84026')
|
|
||||||
.padding({ left: 4 })
|
|
||||||
.transition(TransitionEffect.OPACITY
|
|
||||||
.combine(TransitionEffect.translate({ y: -8 }))
|
|
||||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
|
||||||
}
|
|
||||||
|
|
||||||
PlainCard({ caption: '操作' }) {
|
PlainCard({ caption: '操作' }) {
|
||||||
Row() {
|
Row() {
|
||||||
// 缩放反馈由 MotionBase 统一;连接态的底色/字色切换仍需按钮自己缓动,
|
|
||||||
// 因为父容器的 .animation() 到不了子节点。
|
|
||||||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
|
||||||
Button(this.bridgeConnected ? '断开' : '连接网关')
|
|
||||||
.height(34)
|
|
||||||
.fontSize(12)
|
|
||||||
.backgroundColor(this.bridgeConnected ? Color.Transparent : this.palette().accent)
|
|
||||||
.fontColor(this.bridgeConnected ? this.palette().textSecondary : Color.White)
|
|
||||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
|
||||||
.border({
|
|
||||||
width: this.bridgeConnected ? 1 : 0,
|
|
||||||
color: this.palette().btnGhostBorder,
|
|
||||||
})
|
|
||||||
.onClick(() => {
|
|
||||||
if (this.bridgeConnected) {
|
|
||||||
deviceBridge.disconnect();
|
|
||||||
} else {
|
|
||||||
this.connectBridge();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Blank()
|
Blank()
|
||||||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||||||
Button('刷新设备')
|
Button('刷新设备')
|
||||||
@ -615,7 +483,7 @@ export struct DevicePage {
|
|||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
|
|
||||||
Text('进入本页自动连接;断开后每 5 秒自动重连。hello 登记能力与授权状态,bind 携带 Token 完成身份绑定。')
|
Text('设备通道由应用前台生命周期统一管理;切换连接配置后会自动使用新地址和 Token。')
|
||||||
.fontSize(11)
|
.fontSize(11)
|
||||||
.fontColor(this.palette().textMuted)
|
.fontColor(this.palette().textMuted)
|
||||||
.margin({ top: 10 })
|
.margin({ top: 10 })
|
||||||
|
|||||||
@ -11,7 +11,9 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR
|
|||||||
import { ANIM_NORMAL, ANIM_SLOW } from '../common/Constants';
|
import { ANIM_NORMAL, ANIM_SLOW } from '../common/Constants';
|
||||||
import { MotionBase } from '../components/MotionBase';
|
import { MotionBase } from '../components/MotionBase';
|
||||||
import { GradientBackground } from '../components/GradientBackground';
|
import { GradientBackground } from '../components/GradientBackground';
|
||||||
import { registerScreensueHandler, installCmdRouter } from '../common/BridgeRouter';
|
import { ScreensuePage } from '../components/ScreensuePage';
|
||||||
|
import { registerScreensueHandler } from '../common/BridgeRouter';
|
||||||
|
import { markForegroundBridgeUIReady } from '../common/DeviceBridgeSession';
|
||||||
import { ScreensuePayload, snapshotComponentId } from '../common/BridgeCaps';
|
import { ScreensuePayload, snapshotComponentId } from '../common/BridgeCaps';
|
||||||
import { window, display } from '@kit.ArkUI';
|
import { window, display } from '@kit.ArkUI';
|
||||||
import { common } from '@kit.AbilityKit';
|
import { common } from '@kit.AbilityKit';
|
||||||
@ -110,14 +112,14 @@ struct Index {
|
|||||||
this.syncSystemBar();
|
this.syncSystemBar();
|
||||||
// 动态读取状态栏/导航栏避让区,实现真正的沉浸式布局(替换硬编码 top:44)
|
// 动态读取状态栏/导航栏避让区,实现真正的沉浸式布局(替换硬编码 top:44)
|
||||||
this.resolveSafeArea();
|
this.resolveSafeArea();
|
||||||
// 设备桥:注册命令路由与 screensue 悬浮层回调
|
// 根 UI 只负责 screensue 呈现;命令路由由前台全局设备桥安装。
|
||||||
installCmdRouter();
|
|
||||||
registerScreensueHandler((payload: ScreensuePayload) => {
|
registerScreensueHandler((payload: ScreensuePayload) => {
|
||||||
this.showScreensue(payload);
|
this.showScreensue(payload);
|
||||||
});
|
});
|
||||||
|
markForegroundBridgeUIReady(getContext(this) as common.UIAbilityContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** agent 下发的 screensue 内容展示(悬浮卡片,倒计时自动关闭;0=常驻)。 */
|
/** agent 下发的 screensue 内容展示(窄屏整页、宽屏右栏;0=常驻)。 */
|
||||||
private showScreensue(payload: ScreensuePayload): void {
|
private showScreensue(payload: ScreensuePayload): void {
|
||||||
this.screensueText = payload.content;
|
this.screensueText = payload.content;
|
||||||
this.screensueCountdown = payload.duration;
|
this.screensueCountdown = payload.duration;
|
||||||
@ -218,7 +220,7 @@ struct Index {
|
|||||||
private updateWideScreen(w: number): void {
|
private updateWideScreen(w: number): void {
|
||||||
const wide: boolean = w >= WIDE_MIN_WIDTH;
|
const wide: boolean = w >= WIDE_MIN_WIDTH;
|
||||||
if (wide !== this.isWide) {
|
if (wide !== this.isWide) {
|
||||||
AppStorage.setOrCreate<boolean>('isWideScreen', wide);
|
AppStorage.set<boolean>('isWideScreen', wide);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -230,6 +232,10 @@ struct Index {
|
|||||||
* 的根因。这里按 currentTab 显式选栈,行为对所有页面一致。
|
* 的根因。这里按 currentTab 显式选栈,行为对所有页面一致。
|
||||||
*/
|
*/
|
||||||
onBackPress(): boolean {
|
onBackPress(): boolean {
|
||||||
|
if (this.screensueVisible) {
|
||||||
|
this.closeScreensue();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return handleBackPress(this.currentTab, this.isWide);
|
return handleBackPress(this.currentTab, this.isWide);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -339,51 +345,42 @@ struct Index {
|
|||||||
// 导航栏本身不吃触摸空白区,避免遮住下层内容点击
|
// 导航栏本身不吃触摸空白区,避免遮住下层内容点击
|
||||||
.hitTestBehavior(HitTestMode.Transparent)
|
.hitTestBehavior(HitTestMode.Transparent)
|
||||||
|
|
||||||
// screensue 悬浮层:agent 推送给用户看的内容(置顶展示)
|
// screensue 是前台内容页:窄屏覆盖整页;宽屏仅覆盖右侧内容栏,
|
||||||
|
// 左侧一级页面与主导航保持可见、可操作。
|
||||||
if (this.screensueVisible) {
|
if (this.screensueVisible) {
|
||||||
Column() {
|
if (this.isWide) {
|
||||||
Row() {
|
Row() {
|
||||||
Circle({ width: 8, height: 8 })
|
Column()
|
||||||
.fill(this.palette().accent)
|
.width(WIDE_NAV_BAR_WIDTH)
|
||||||
Text('agent 推送')
|
.height('100%')
|
||||||
.fontSize(12)
|
.hitTestBehavior(HitTestMode.None)
|
||||||
.fontWeight(FontWeight.Medium)
|
|
||||||
.fontColor(this.palette().textSecondary)
|
ScreensuePage({
|
||||||
.margin({ left: 8 })
|
pushedText: this.screensueText,
|
||||||
Blank()
|
countdown: this.screensueCountdown,
|
||||||
if (this.screensueCountdown > 0) {
|
onClose: () => {
|
||||||
Text(this.screensueCountdown.toString() + 's')
|
|
||||||
.fontSize(11)
|
|
||||||
.fontColor(this.palette().textMuted)
|
|
||||||
}
|
|
||||||
Text('关闭')
|
|
||||||
.fontSize(12)
|
|
||||||
.fontColor(this.palette().accent)
|
|
||||||
.padding({ left: 10, right: 2, top: 4, bottom: 4 })
|
|
||||||
.onClick(() => {
|
|
||||||
this.closeScreensue();
|
this.closeScreensue();
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
.layoutWeight(1)
|
||||||
|
.height('100%')
|
||||||
|
.padding({ top: this.topInset })
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.margin({ bottom: 10 })
|
.height('100%')
|
||||||
|
.hitTestBehavior(HitTestMode.Transparent)
|
||||||
Scroll() {
|
} else {
|
||||||
Text(this.screensueText)
|
ScreensuePage({
|
||||||
.fontSize(15)
|
pushedText: this.screensueText,
|
||||||
.fontColor(this.palette().textPrimary)
|
countdown: this.screensueCountdown,
|
||||||
.width('100%')
|
onClose: () => {
|
||||||
}
|
this.closeScreensue();
|
||||||
.constraintSize({ maxHeight: 320 })
|
},
|
||||||
.scrollBar(BarState.Auto)
|
})
|
||||||
.align(Alignment.Top)
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.padding({ top: this.topInset })
|
||||||
}
|
}
|
||||||
.width('86%')
|
|
||||||
.padding(18)
|
|
||||||
.borderRadius(18)
|
|
||||||
.backgroundColor(this.palette().bgCard)
|
|
||||||
|
|
||||||
.border({ width: 1, color: this.palette().glassBorder })
|
|
||||||
.shadow({ radius: 32, color: this.palette().shadow, offsetY: 10 })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../co
|
|||||||
import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
|
import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||||
import { StatusSummaryCard, StatusDetailContent } from '../components/StatusCards';
|
import { StatusSummaryCard, StatusDetailContent } from '../components/StatusCards';
|
||||||
import { statusStore } from '../common/StatusStore';
|
import { statusStore } from '../common/StatusStore';
|
||||||
|
import { restartForegroundBridge } from '../common/DeviceBridgeSession';
|
||||||
|
|
||||||
/** One settings key card rendered in the editor list. */
|
/** One settings key card rendered in the editor list. */
|
||||||
interface SettingEntry {
|
interface SettingEntry {
|
||||||
@ -201,6 +202,7 @@ export struct SettingsPage {
|
|||||||
apiClient.setConnection(cur);
|
apiClient.setConnection(cur);
|
||||||
}
|
}
|
||||||
this.loadConnections();
|
this.loadConnections();
|
||||||
|
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||||
this.showToast('已切换连接', false);
|
this.showToast('已切换连接', false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -224,6 +226,7 @@ export struct SettingsPage {
|
|||||||
apiClient.setConnection(cur);
|
apiClient.setConnection(cur);
|
||||||
}
|
}
|
||||||
this.loadConnections();
|
this.loadConnections();
|
||||||
|
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||||
this.showToast('连接已添加', false);
|
this.showToast('连接已添加', false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -242,6 +245,7 @@ export struct SettingsPage {
|
|||||||
apiClient.setConnection(cur);
|
apiClient.setConnection(cur);
|
||||||
}
|
}
|
||||||
this.loadConnections();
|
this.loadConnections();
|
||||||
|
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||||
this.showToast('连接已更新', false);
|
this.showToast('连接已更新', false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -278,10 +282,13 @@ export struct SettingsPage {
|
|||||||
private deleteConnection(id: string): void {
|
private deleteConnection(id: string): void {
|
||||||
connStore.deleteConnection(id).then(() => {
|
connStore.deleteConnection(id).then(() => {
|
||||||
this.loadConnections();
|
this.loadConnections();
|
||||||
const cur = connStore.getCurrentConnection();
|
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
|
||||||
if (cur !== null) {
|
if (cur !== null) {
|
||||||
apiClient.setConnection(cur);
|
apiClient.setConnection(cur);
|
||||||
|
} else {
|
||||||
|
apiClient.clearConnection();
|
||||||
}
|
}
|
||||||
|
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||||
this.showToast('连接已删除', false);
|
this.showToast('连接已删除', false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,14 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "ohos.permission.GET_NETWORK_INFO"
|
"name": "ohos.permission.GET_NETWORK_INFO"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ohos.permission.READ_PASTEBOARD",
|
||||||
|
"reason": "$string:read_pasteboard_reason",
|
||||||
|
"usedScene": {
|
||||||
|
"abilities": ["EntryAbility"],
|
||||||
|
"when": "inuse"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"abilities": [
|
"abilities": [
|
||||||
|
|||||||
@ -3,6 +3,10 @@
|
|||||||
{
|
{
|
||||||
"name": "app_name",
|
"name": "app_name",
|
||||||
"value": "HomeAgent"
|
"value": "HomeAgent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "read_pasteboard_reason",
|
||||||
|
"value": "用于在应用前台按你的授权响应 agent 的剪贴板读取请求"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,7 +39,7 @@ type msgEntry struct {
|
|||||||
|
|
||||||
type daemonHandler struct {
|
type daemonHandler struct {
|
||||||
// homed 连接
|
// homed 连接
|
||||||
homeMu sync.Mutex
|
homeMu sync.Mutex
|
||||||
homeConn net.Conn
|
homeConn net.Conn
|
||||||
homeR *bufio.Reader
|
homeR *bufio.Reader
|
||||||
homeCfg *Config
|
homeCfg *Config
|
||||||
@ -304,14 +304,14 @@ func startDaemonDeviceBridge(cfg *Config) {
|
|||||||
if dg == "" || dt == "" {
|
if dg == "" || dt == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 设备桥重连循环:WS 断开时自动重连
|
// 设备桥重连循环:WS 断开时自动重连,并保留配置中的本地授权状态。
|
||||||
go runDeviceBridgeLoop(dg, dt)
|
go runDeviceBridgeLoop(dg, dt, cfg.DeviceAuthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runDeviceBridgeLoop 无限重连循环:建立设备桥 → 等待断开 → 重连。
|
// runDeviceBridgeLoop 无限重连循环:建立设备桥 → 等待断开 → 重连。
|
||||||
func runDeviceBridgeLoop(gateway, token string) {
|
func runDeviceBridgeLoop(gateway, token string, authorized bool) {
|
||||||
for {
|
for {
|
||||||
bridge, err := connectDeviceBridge(gateway, token)
|
bridge, err := connectDeviceBridge(gateway, token, authorized)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[daemon] device bridge connect failed: %v, retrying in 5s", err)
|
log.Printf("[daemon] device bridge connect failed: %v, retrying in 5s", err)
|
||||||
time.Sleep(5 * time.Second)
|
time.Sleep(5 * time.Second)
|
||||||
@ -325,7 +325,7 @@ func runDeviceBridgeLoop(gateway, token string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// connectDeviceBridge 创建并启动一次设备桥,返回 bridge 实例供 Wait()。
|
// connectDeviceBridge 创建并启动一次设备桥,返回 bridge 实例供 Wait()。
|
||||||
func connectDeviceBridge(gateway, token string) (*client.Bridge, error) {
|
func connectDeviceBridge(gateway, token string, authorized bool) (*client.Bridge, error) {
|
||||||
hostname, _ := os.Hostname()
|
hostname, _ := os.Hostname()
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
hostname = "local"
|
hostname = "local"
|
||||||
@ -355,12 +355,17 @@ func connectDeviceBridge(gateway, token string) (*client.Bridge, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bridge := client.New(gw, token, deviceID, hostname, caps, info)
|
bridge := client.New(gw, token, deviceID, hostname, caps, info)
|
||||||
|
bridge.SetAuthorized(authorized)
|
||||||
|
|
||||||
// 注册命令处理器
|
// 注册命令处理器:cmd_type 是主信号,同时兼容旧版 homeagent-* 文本前缀。
|
||||||
cr := client.NewCmdRouter()
|
cr := client.NewCmdRouter()
|
||||||
cr.Handle("homeagent-", handleHomeagentCmd)
|
cr.Handle("homeagent-", handleHomeagentCmd)
|
||||||
cr.HandleDefault(handleShellCmd)
|
cr.HandleDefault(handleShellCmd)
|
||||||
bridge.OnCmd(func(reqID, command string) {
|
bridge.OnCmd(func(reqID, command, cmdType string) {
|
||||||
|
if cmdType == "homeagent" {
|
||||||
|
handleHomeagentCmd(reqID, command)
|
||||||
|
return
|
||||||
|
}
|
||||||
cr.Dispatch(reqID, command)
|
cr.Dispatch(reqID, command)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -371,8 +376,6 @@ func connectDeviceBridge(gateway, token string) (*client.Bridge, error) {
|
|||||||
// 设置全局变量供 sendBridgeResult 使用
|
// 设置全局变量供 sendBridgeResult 使用
|
||||||
deviceBridge = bridge
|
deviceBridge = bridge
|
||||||
deviceBridgeID = deviceID
|
deviceBridgeID = deviceID
|
||||||
auth := true // daemon 模式默认授权(配置已指定)
|
|
||||||
bridge.SetAuthorized(auth)
|
|
||||||
|
|
||||||
return bridge, nil
|
return bridge, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,13 +61,22 @@ func startDeviceBridge(addr, token string) error {
|
|||||||
bridge := client.New(gateway, token, deviceID, "HomeAgent CLI", caps, info)
|
bridge := client.New(gateway, token, deviceID, "HomeAgent CLI", caps, info)
|
||||||
cmdRouter = client.NewCmdRouter()
|
cmdRouter = client.NewCmdRouter()
|
||||||
|
|
||||||
// 注册命令处理器
|
// cmd_type 是主路由信号;保留 homeagent-* 文本前缀兼容旧服务端。
|
||||||
cmdRouter.Handle("homeagent-", handleHomeagentCmd)
|
cmdRouter.Handle("homeagent-", handleHomeagentCmd)
|
||||||
cmdRouter.HandleDefault(handleShellCmd)
|
cmdRouter.HandleDefault(handleShellCmd)
|
||||||
bridge.OnCmd(func(reqID, command string) {
|
bridge.OnCmd(func(reqID, command, cmdType string) {
|
||||||
|
if cmdType == "homeagent" {
|
||||||
|
handleHomeagentCmd(reqID, command)
|
||||||
|
return
|
||||||
|
}
|
||||||
cmdRouter.Dispatch(reqID, command)
|
cmdRouter.Dispatch(reqID, command)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// agent 主动投递(output_send__device/<id>)→ 终端显示。
|
||||||
|
// 设备侧参考实现:文本/结构化直接打出来;二进制负载走 OnData(TTS 音频等)。
|
||||||
|
bridge.OnPush(func(reqID, typ, payload, meta string) {
|
||||||
|
printlnC("cyan", fmt.Sprintf("[push:%s] %s", typ, payload))
|
||||||
|
})
|
||||||
if err := bridge.Start(); err != nil {
|
if err := bridge.Start(); err != nil {
|
||||||
return fmt.Errorf("device bridge: %w", err)
|
return fmt.Errorf("device bridge: %w", err)
|
||||||
}
|
}
|
||||||
@ -767,4 +776,4 @@ func sanitizeID(s string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|||||||
631
docs/zh/input-scheduler-design.md
Normal file
631
docs/zh/input-scheduler-design.md
Normal file
@ -0,0 +1,631 @@
|
|||||||
|
# 输入调度器设计(四级中断优先级 · 两类别 · 可抢占 · 现场保存)
|
||||||
|
|
||||||
|
> **模型更正(2026-09-13,据用户澄清重写 §2/§3/§4.1/§6.3/§9/§11/§12)**
|
||||||
|
>
|
||||||
|
> 本稿早期版本把「四级优先级」当成了**所有任务**的通用优先级,并按通道名
|
||||||
|
> (qq→L2、cli→L3)由内核推断级别。那是错的。正确模型是**两类别 + 四级**:
|
||||||
|
>
|
||||||
|
> | | 中断输入(interrupt) | 排队输入(queued) |
|
||||||
|
> |---|---|---|
|
||||||
|
> | 注入 API | `InjectInterrupt*` | `InjectText*` / `InjectInputSync*` / 内核自循环 |
|
||||||
|
> | 级别 | L1–L4 | **无级别** |
|
||||||
|
> | 定位 | 需要及时处理 | 不需要及时处理 |
|
||||||
|
> | 可被谁打断 | 仅**严格更高级**的中断 | **任何**中断 |
|
||||||
|
>
|
||||||
|
> 级别(“这项工作有多不能等”)由来源在 `InjectOptions.Priority` 里声明。
|
||||||
|
> L1–L3 任何插件可声明;**L4 是“立即打断”能力**,只有**内核自身**(panic /
|
||||||
|
> 内核事件 selfip,经 `raiseKernelInterrupt`)与**内核级插件**(编译期内置插件,
|
||||||
|
> 如 WebUI 的终止按钮)能用。外部插件的 L4 会被夹到 L3。
|
||||||
|
> 类别由**用哪个注入 API**决定,与通道名无关——QQ 走的是 `InjectInterruptTextOpts`,
|
||||||
|
> 所以它是**低级别中断(L1)**,不是排队输入。
|
||||||
|
|
||||||
|
> 分支:`feature/input-semantics`
|
||||||
|
> 状态:**设计稿 v1**(待确认项见 §12,未确认处按 §12 的「默认取值」推进)
|
||||||
|
> 影响面:`internal/agent/core`、`internal/agent/io`、`internal/sdk`(**仅内部**)
|
||||||
|
> 公开 SDK:**v1 不改**(`third_party/homeagent-sdk/sdk/` diff 必须保持为 0,理由见 §13)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景:现状与要解决的问题
|
||||||
|
|
||||||
|
现有两条输入语义(见审查结论),都建立在**串行 `eventLoop`** 之上:
|
||||||
|
|
||||||
|
| 语义 | 入口 | 路径 |
|
||||||
|
|---|---|---|
|
||||||
|
| 排队 | `InjectText` / `InjectInput*` | `io.inputCh` → `eventLoop` → `processInput` → `process()` |
|
||||||
|
| 中断 | `InjectInterruptText` / `InjectInterrupt*` | `io.interruptCh` → `interceptLoop` → `cancelLLM` + `a.interceptCh` |
|
||||||
|
|
||||||
|
已确认的具体问题(均为源码事实):
|
||||||
|
|
||||||
|
1. **队头阻塞**:`eventLoop` 单 goroutine,`process()` 全程持 `a.mu`(`internal/agent/core/process.go:103-104`),一轮对话(含 N 轮工具)期间后续输入全部排队。
|
||||||
|
2. **中断只在一种时刻成立**:`interceptLoop` 有三条降级回排队的路径——当前无 LLM 在跑、当前轮是 `_consolidation_`、`a.interceptCh` 满(`internal/agent/core/eventloop.go:74-116`)。它不是独立管线,而是"抢占 + 三次降级"。
|
||||||
|
3. **回执误投风险**:`ResponseCh` 只由全局 `emitResponse` 写(`eventloop.go:475`),无任务归属。一旦引入抢占,中断的回执会写进被挂起任务的等待者。
|
||||||
|
4. **断链点静默**:`processInput` 有多条提前 `return` 而不 `emitResponse` 的路径(`resolveInput` 失败 `:309`、10s 去重命中 `:317`、`_consolidation_` `:326`),同步调用方(`cli`、`clawhubadapter` 无超时)永久挂起。
|
||||||
|
5. **背压策略分裂**:`inputCh` 满 → 阻塞发送方;`selfInputCh` 满 → 静默丢弃;`a.interceptCh` 满 → 降级回 `inputCh`。
|
||||||
|
6. **假取消**:工具超时只是放弃等待,内层继续执行且副作用照做(`internal/agent/core/toolcall.go:33-45`)。
|
||||||
|
7. **不可观测**:没有任何"当前在跑什么、谁被挂起、降级了多少次"的统一入口。
|
||||||
|
|
||||||
|
**目标**:把上述隐式行为收敛成一个**显式、可 dump、可单测、可断言**的调度器。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 术语与模型
|
||||||
|
|
||||||
|
```
|
||||||
|
Task = { id, class, level, origin, frame, state, enqueueAt, preemptCount, responseCh }
|
||||||
|
class: queued | interrupt // **类别由注入 API 决定,与通道名无关**
|
||||||
|
queued —— 无级别;用于“不需及时处理”的场景;可被**任何**中断打断
|
||||||
|
interrupt —— 带级别 L1..L4;仅被**严格更高级**的中断打断(被打断则压入中断栈)
|
||||||
|
level: 仅 interrupt 有意义(queued 恒无级别,effectiveLevel 视作 0)
|
||||||
|
state: ready | running | suspended | done
|
||||||
|
|
||||||
|
TaskFrame = {
|
||||||
|
msgs []Message // 本任务自己的 LLM 消息序列
|
||||||
|
step Step // 下一个要执行的 step(安全点游标)
|
||||||
|
turn int // 已完成的工具轮数
|
||||||
|
toolIdx int // 当前工具批内的下标
|
||||||
|
toolResults []ToolResultItem
|
||||||
|
toolsUsed []string
|
||||||
|
lastBatchReplyOnly bool
|
||||||
|
stageCtx *sdk.StageContext
|
||||||
|
budget TokenBudget
|
||||||
|
outputChannel string // 该任务的输出通道
|
||||||
|
responseCh chan<- *OutputEvent // 任务级回执信道(可为 nil)
|
||||||
|
startedAt time.Time
|
||||||
|
input string // 触发本任务的输入文本(COMMIT 时写记忆)
|
||||||
|
noMemory bool
|
||||||
|
originSource string
|
||||||
|
}
|
||||||
|
|
||||||
|
Step(枚举,顺序执行,步与步之间是安全点):
|
||||||
|
S_PREPARE 构建 msgs / 应用中断标记 / 合并 stage 上下文
|
||||||
|
S_LLM LLM 流式调用(**可抢占**:cancel 即丢弃)
|
||||||
|
S_POST StagePostAction
|
||||||
|
S_LLM_JUDGE 无 tool_call → 去 S_BEFORE_OUTPUT;有 → 去 S_TOOL_BEGIN
|
||||||
|
S_TOOL_BEGIN replyOnly 判定 + 取当前 tc
|
||||||
|
S_TOOL_BEFORE StageBeforeToolcall
|
||||||
|
S_TOOL_EXEC 工具执行(**临界区,不可抢占**)
|
||||||
|
S_TOOL_AFTER StageAfterToolcall
|
||||||
|
S_TOOL_NEXT 批内下一个 / 批结束 → S_LLM
|
||||||
|
S_BEFORE_OUTPUT / S_AFTER_OUTPUT
|
||||||
|
S_COMMIT context.Append + emitMemoryCandidate(**原子,不可抢占**)
|
||||||
|
S_FINISH 写 responseCh、发事件
|
||||||
|
```
|
||||||
|
|
||||||
|
四个容器(**不是**“三集合并成一个比较器”):
|
||||||
|
|
||||||
|
| 容器 | 内容 | 取出规则 |
|
||||||
|
|---|---|---|
|
||||||
|
| `interruptQueues[1..4]` | **中断队列**,每条队列一个级别 | 从 L4 到 L1 依次扫描;同级 FIFO |
|
||||||
|
| `immediate` | 刚抢占成功的那一条中断(**至多一个**) | 最先取出——抢占必须**立即生效** |
|
||||||
|
| `queue` | **排队输入**形成的新任务 | 纯 FIFO(无级别可比) |
|
||||||
|
| `suspendStack`(**中断栈**) | 被打断、保存了现场的任务 | **LIFO,只比栈顶**;栈内不做重排 |
|
||||||
|
|
||||||
|
> 用词(已更正):它**就是中断栈**。用户明确存在「中断被中断」的场景,被打断的现场必须压栈;
|
||||||
|
> 因此恢复纪律是**严格 LIFO(只比栈顶)**,栈内不做优先级重排。
|
||||||
|
> 早期稿把它写成“不是栈、按优先级取”是错的。
|
||||||
|
>
|
||||||
|
> 早期稿还让 `immediate` 与别的容器共用同一个比较器,于是出现“抢占成功后,
|
||||||
|
> 抢占者与被挂起者同级 → 原任务被立刻选回 → 抢占空转”——为此打的
|
||||||
|
> “同级 pending 优先”补丁已删除:抢占者根本不进队列。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 优先级(只属于中断)
|
||||||
|
|
||||||
|
### 3.1 两类别 + 四级
|
||||||
|
|
||||||
|
**类别(`TaskClass`)由注入 API 决定,与通道名无关**:
|
||||||
|
|
||||||
|
| 类别 | 注入入口 | 级别 | 可被谁打断 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `queued` 排队 | `InjectText*` / `InjectInputSync*` / `InjectInputMedia*` / 内核自循环(`selfInputCh`) | **无** | **任何**中断(L1 也能) |
|
||||||
|
| `interrupt` 中断 | `InjectInterrupt*` | L1–L4 | 仅**严格更高级**的中断 |
|
||||||
|
|
||||||
|
**级别(`Level`)语义是“这项工作有多不能等”**:
|
||||||
|
|
||||||
|
| Level | 名称 | 语义 | 典型来源 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `L4` | CRITICAL | 内核紧急 | **内核独占**:panic 中断、内核事件中断(selfip) |
|
||||||
|
| `L3` | INTERACTIVE | 需及时处理 | 时钟/定时器到达、终端输出、交互输入 |
|
||||||
|
| `L2` | MESSAGE | 一般提醒 | 插件希望尽快看到、但不紧急的提示 |
|
||||||
|
| `L1` | BACKGROUND | 完全可等 | 异步消息(QQ/微信)、批量通知 |
|
||||||
|
|
||||||
|
- **`queued` 没有级别**:它本就是“不需及时处理”的那一类,
|
||||||
|
所以“可被任何中断打断”不是漏洞而是定义(`effectiveLevel(queued) == 0`)。
|
||||||
|
- **默认级 = `L1`**:未声明一律最低级(“显式才是特权”,新插件不会默认拿到抢占权)。
|
||||||
|
|
||||||
|
### 3.2 级别从哪来
|
||||||
|
|
||||||
|
| 来源 | 可达级别 | 入口 |
|
||||||
|
|---|---|---|
|
||||||
|
| 普通插件(外部,独立进程/动态库) | L1–L3 | `InjectOptions.Priority`(空/非法 → L1;L4 被夹到 L3) |
|
||||||
|
| **内核级插件**(编译期内置,`init()` 自注册) | L1–**L4** | 同上;L4 用于实现**中断能力**,例如 WebUI 的终止按钮 |
|
||||||
|
| 内核自身 | L4 | `(*Agent).raiseKernelInterrupt`(panic / selfip) |
|
||||||
|
|
||||||
|
- ❌ **不是运维可调项**。不引入 `core.agent.priority.<channel>` 这类配置键,
|
||||||
|
也不把 `PriorityLookup` 做成可注入的策略表。
|
||||||
|
- ✅ 插件**可以声明**自己中断的级别(这不是“把内核内部属性外化”,
|
||||||
|
而是调用方声明它自己那件事有多不能等)。
|
||||||
|
- ✅ **L4 给“立即打断”能力**:内核自身(panic / selfip)与**内核级插件**
|
||||||
|
(编译期内置插件,如 WebUI 终止按钮)可声明。为什么必须给内置插件:
|
||||||
|
用户按下终止按钮时,内核需要一条能立刻打断当前任务的中断;这条能力不能给
|
||||||
|
外部插件,否则任何第三方插件都能随时打断用户的一切工作。
|
||||||
|
- **判据是“这个插件是不是编译期内置”,不是它自报的名字**:
|
||||||
|
- 第一道闸在 **proc 桥**(外部进程的唯一入口):走它的一律把 L4 夹到 L3。
|
||||||
|
在这里夹而不是只按 `source` 判,是因为 `source` 是插件自报字段、可以冒名。
|
||||||
|
- 第二道闸在 **core**:`isKernelLevelSource(source)` 查
|
||||||
|
`pluginReg.IsBuiltinPlugin`,只有内置工厂才承认 L4(纵深防御)。
|
||||||
|
- `source` 的约定是 `插件名` 或 `插件名/实例`(如 `webui/<deviceID>`),
|
||||||
|
判据取第一段——否则带设备身份的 WebUI 来源会被误判成外部插件。
|
||||||
|
|
||||||
|
### 3.3 抢占判据
|
||||||
|
|
||||||
|
```go
|
||||||
|
effectiveLevel(queued) == 0
|
||||||
|
canPreempt(incoming, running) = incoming.Class == TaskInterrupt
|
||||||
|
&& effectiveLevel(incoming) > effectiveLevel(running)
|
||||||
|
```
|
||||||
|
|
||||||
|
因为 `queued` 的有效级恒为 0,这一个比较同时覆盖两条规则:
|
||||||
|
|
||||||
|
```
|
||||||
|
running 是排队任务 → 任何中断(≥L1)都抢占
|
||||||
|
running 是中断 Li → 只有 Lj > Li 的中断抢占(严格大于)
|
||||||
|
incoming 是排队输入 → 永不抢占
|
||||||
|
```
|
||||||
|
|
||||||
|
**严格大于才抢占**;相等一律入队——这条保证确定性,也是“较低无法打断较高”的字面实现。
|
||||||
|
|
||||||
|
## 4. 调度规则
|
||||||
|
|
||||||
|
### 4.1 选择函数(四容器 · 固定次序)
|
||||||
|
|
||||||
|
任务结束、或运行任务到达安全点且存在待处理抢占请求时,执行:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. immediate 非空 → 取它(刚抢占成功的中断,抢占必须立即生效)
|
||||||
|
2. 中断队列非空 → 取 L4→L1 中最高级非空队列的队头(同级 FIFO)
|
||||||
|
3. 中断栈非空(与 2 比高) → 栈顶有效级 ≥ 队头级别 ? 弹栈顶 : 取队头
|
||||||
|
4. queue 非空 → 取队头(纯 FIFO)
|
||||||
|
5. 都没有 → 空闲(阻塞等新输入 / 新中断)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **中断栈只把栈顶**放进比较(严格 LIFO)——栈内更老的任务即使因饥饿防护
|
||||||
|
提升了有效级,也不得越过栈顶;“后被打断的先恢复”才是栈语义。
|
||||||
|
- 第 3 步就是用户给的规则:“先判断中断队列是否为空,同时判断中断栈中任务的
|
||||||
|
优先级,哪个优先级高取出哪个”。栈顶是 `queued`(有效级 0)时,任何中断都赢。
|
||||||
|
- 第 1 步的存在,使“抢占者与被抢占者同级”这个比较**根本不会发生**:
|
||||||
|
抢占者不经队列。这是删除早期“同级 pending 优先”补丁后的正确形态。
|
||||||
|
- 排队任务只在中断与挂起现场都处理完后才执行——这正对应“排队输入用于
|
||||||
|
不需要及时处理的场景”。
|
||||||
|
|
||||||
|
### 4.2 安全点(可切换点)
|
||||||
|
|
||||||
|
**只有 step 与 step 之间是安全点。** 明确:
|
||||||
|
|
||||||
|
- ✅ `S_LLM` 之后、`S_TOOL_BEFORE` 之后、`S_TOOL_EXEC` **之后**、`S_TOOL_AFTER` 之后……
|
||||||
|
- ❌ `S_TOOL_EXEC` **执行中不是安全点**:工具副作用不可回滚,无法"保存现场"。
|
||||||
|
|
||||||
|
### 4.3 临界区
|
||||||
|
|
||||||
|
```
|
||||||
|
CriticalSection:step 标记 nonPreemptible = true,或任务进入声明区间
|
||||||
|
```
|
||||||
|
|
||||||
|
- 实现方式:**调度器在临界区期间不求值抢占**(协作式单线程下即"不 yield"),不使用 `sync.Mutex`。
|
||||||
|
- 资源互斥不用锁,而是**调度器持有的资源表**(若某资源被 running 占用,则不会选出同样占用它的任务)——纯数据判定,天然无优先级反转。
|
||||||
|
- **v1 临界区清单**(显式列出,避免"隐式临界区"):
|
||||||
|
|
||||||
|
| 临界区 | 理由 |
|
||||||
|
|---|---|
|
||||||
|
| `S_TOOL_EXEC`(单次工具执行全程) | 副作用不可回滚;插件 RPC 不可取消 |
|
||||||
|
| `S_COMMIT` | 上下文/记忆写入必须原子 |
|
||||||
|
| 需 ONNX 嵌入的 `S_PREPARE` 片段 | ONNX `Run` 不可取消 |
|
||||||
|
| 媒体 CAS 落盘 | 同上 |
|
||||||
|
| 显式声明的 `_consolidation_` 类任务 | 记忆一致性 |
|
||||||
|
|
||||||
|
- 临界区期间到达的抢占请求**不丢失**:按级别进入中断队列,在临界区结束后的第一个安全点重新求值。
|
||||||
|
|
||||||
|
### 4.4 背压(v1 统一为一种)
|
||||||
|
|
||||||
|
- `readyQueue` 有界(默认 256,可配)。
|
||||||
|
- 满时:**阻塞发送方**(与现状 `inputCh` 一致,避免静默丢用户输入),但必须**计数并打日志**。
|
||||||
|
- 中断队列合计有界(默认同 `maxQueue`);满时**丢弃最低级别里最老的一条并计数**(中断是提示性输入,宁可丢旧保新)。
|
||||||
|
- 中断栈帧数上界是**结构推论 = 4**(见 §6.3),不是配置项。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 中断语义
|
||||||
|
|
||||||
|
### 5.1 中断产生线程的职责(钉死)
|
||||||
|
|
||||||
|
`interruptLoop` 只做三件事,**绝不触碰任何 TaskFrame**:
|
||||||
|
|
||||||
|
```
|
||||||
|
① 从 io.interruptCh 收中断 → 定级(读 payload["priority"],插件声明 L1..L3)
|
||||||
|
② 决策(scheduler.registerInterrupt 内):
|
||||||
|
canPreempt(incoming, running) 且 running 不在临界区
|
||||||
|
→ 置让位信号 + 把 incoming 放进 immediate 槽,并返回 true(调用方据此
|
||||||
|
取消当前可取消的 step,即 LLM 流式)
|
||||||
|
否则
|
||||||
|
→ 按级别进入对应的中断队列
|
||||||
|
③ 唤醒调度器(scheduler.wake,cap 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
共享面仅三处:让位信号(`preemptArmed`/`preemptLevel`)、中断队列、`critical` 原子标志。
|
||||||
|
**帧的保存与恢复只能由调度器做。**
|
||||||
|
|
||||||
|
### 5.2 三种情形的统一
|
||||||
|
|
||||||
|
现状的三条降级路径在新模型里不再需要特殊分支:
|
||||||
|
|
||||||
|
| 情形 | 旧模型 | 新模型 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| LLM 在跑,正常 | 真抢占(同轮 continue) | 真抢占:`S_LLM` 取消,任务 A **压入中断栈**,中断任务 B 从 `S_PREPARE` 启动 |
|
||||||
|
| LLM 没在跑 | 降级为排队 | B 按其级别入中断队列(空闲时即被 `wake` 唤醒并选出) |
|
||||||
|
| `_consolidation_` 中 | 降级为排队 | `_consolidation_` 是后台**临界区**(且它是排队任务)→ B 入中断队列,临界区结束后求值 |
|
||||||
|
| `a.interceptCh` 满 | 降级为排队 | 不存在该队列;中断队列有界,满则丢最低级别里最老的一条 |
|
||||||
|
|
||||||
|
### 5.3 中断任务与被打断任务的关系(**已定:D1 = 方案 B**)
|
||||||
|
|
||||||
|
> 用户明确:
|
||||||
|
> *“中断打断时,上个任务到达以来的所有上下文现场被保护(含 toolcall),
|
||||||
|
> 然后中断在**上个任务前的那个完整状态**上开始运行。中断运行结束,再把被挂起的
|
||||||
|
> 任务与其上下文现场**加载回中断任务之上**,并继续运行。”*
|
||||||
|
|
||||||
|
因此语义是:
|
||||||
|
|
||||||
|
1. **被挂起任务的现场 = 它自到达以来累积的全部上下文(含 toolcall 结果)**,
|
||||||
|
原样保存在 `TaskFrame` 里。
|
||||||
|
2. **中断任务从「上一个任务之前的完整状态」开始运行**——它**看不到**被打断
|
||||||
|
任务的任何部分进展。等价于:中断任务就是一个普通新任务,正常走 `S_PREPARE`
|
||||||
|
(重建 system prompt + timeline + 自己的输入)。
|
||||||
|
3. **中断结束后,把被挂起任务与其现场加载回「中断任务之上」再继续**:
|
||||||
|
中断已提交的那段上下文留在**下面**(成为重建前缀的一部分),本任务自己的
|
||||||
|
现场接回**其上**。
|
||||||
|
|
||||||
|
实现对应(`internal/agent/core/task.go`):
|
||||||
|
|
||||||
|
- `TaskFrame.PrefixLen` 记录 prepare 段构建的**基础前缀**长度
|
||||||
|
(system + timeline + 用户输入);其后的 Stage 上下文与工具轮产物都是“自己的现场”。
|
||||||
|
- `rebaseFramePrefix(f)`:恢复时重建基础前缀(因中断结束已把它的输入/输出提交进
|
||||||
|
`a.context`,重建出的 timeline 已含中断效果),再把 `f.Msgs[PrefixLen:]` 原样接回;
|
||||||
|
并补回 prepare 段的両处尾部改写(`IsInterrupt` 的 `[中断消息]` 标记、输入多模态块)。
|
||||||
|
- 调用点:`resumeTask` 在 `runTaskSteps` **之前**调用它。
|
||||||
|
|
||||||
|
> 代价(已知且接受):中断看不到“进行到哪一步”,所以“别搜了改成 X”这类指令
|
||||||
|
> 只能靠它自己重新理解;换来的是中断起点总是一个**一致的完整状态**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 保存现场与恢复
|
||||||
|
|
||||||
|
### 6.1 保存
|
||||||
|
|
||||||
|
在安全点被抢占时:
|
||||||
|
|
||||||
|
```
|
||||||
|
suspendStack.push(Task{frame: running.frame, state: suspended,
|
||||||
|
step: running.frame.step, enqueueAt: running.enqueueAt})
|
||||||
|
running.state = done_for_now
|
||||||
|
```
|
||||||
|
|
||||||
|
- **只保存数据帧**,不保存 goroutine 栈(这正是"单调度 + 隐式状态机"优于"park goroutine"的地方)。
|
||||||
|
- `S_LLM` 被抢占时:**不完整的 LLM 请求直接丢弃**(LLM 调用幂等、无持久副作用);恢复时从 `S_LLM` **重发**,`msgs` 与抢占前一致(即"请求前"的状态)。
|
||||||
|
- 已提交的副作用(已执行的工具、已 append 的 context)**不回滚**——帧里记录的 `toolResults` 会保留,恢复后继续。
|
||||||
|
|
||||||
|
### 6.2 恢复
|
||||||
|
|
||||||
|
从**中断栈栈顶**取出后:
|
||||||
|
|
||||||
|
1. **重建基础前缀**(`rebaseFramePrefix`)—— 此时中断任务已结束并提交,
|
||||||
|
重建出的 timeline 包含中断的输入/输出,即“现场加载回中断任务之上”;
|
||||||
|
2. 把本任务自己的尾部(Stage 上下文 + 工具轮产物 + 占位)原样接回;
|
||||||
|
3. 从 `frame.Step` 继续执行。
|
||||||
|
|
||||||
|
被丢弃的只有那次**不完整的 LLM 请求**(幂等),已执行的工具与已累积的
|
||||||
|
`toolResults` 全部保留。
|
||||||
|
|
||||||
|
### 6.3 嵌套
|
||||||
|
|
||||||
|
- 允许中断任务自身被更高级中断抢占(嵌套)。
|
||||||
|
- **中断栈帧数上界 = 4,是结构推论而不是配置项**:
|
||||||
|
链条 = `排队(L0) ← I(L1) ← I(L2) ← I(L3) ← I(L4 运行中)`,
|
||||||
|
被挂起 4 帧;L4 之上没有更高级别,链到此为止。
|
||||||
|
(插件可达级别只到 L3,所以插件链最多挂起 3 帧 + 底层排队任务;
|
||||||
|
第 4 帧只能由内核 L4 制造。)
|
||||||
|
- 栈自底向上的**基础级**天然递增(能被抢占者必然级别更高),因此栈顶通常就是最高级任务。
|
||||||
|
- 超限在正确模型下不可达:`susp` 处只做**防御性计数**(`Rejected++`),
|
||||||
|
**不降级、不丢弃帧**——帧丢了会丢副作用记录。早期稿写的“超限转 pendingInterrupts”已删除。
|
||||||
|
|
||||||
|
## 7. 回执路由(任务级)
|
||||||
|
|
||||||
|
**必须改**:`ResponseCh` 从"全局 `emitResponse` 的对象"上升为 `TaskFrame.responseCh`。
|
||||||
|
|
||||||
|
```
|
||||||
|
emitResponse(task, ...) // 写 task.frame.responseCh,而不是"当前全局通道"
|
||||||
|
```
|
||||||
|
|
||||||
|
- 抢占场景下,中断任务 B 的 `S_FINISH` 只可能写 `B.responseCh`,绝不会写进被挂起的 `A.responseCh`。
|
||||||
|
- **不变量**:**每个任务在 `S_FINISH` 必然产生且仅产生一个终态事件**(无论成功、失败、被跳过)。`processInput` 现有的三个提前 return(解析失败、去重、consolidation)在新模型里都必须转成"任务以 `skipped` 终态结束并回执"。
|
||||||
|
- 这顺带修掉现有缺陷:`cli`(`internal/plugins/cli/plugin.go:242`)与 `clawhubadapter`(`internal/plugins/clawhubadapter/plugin.go:1045`)的同步注入在断链时会永久挂起。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 并发结构(两个 goroutine)
|
||||||
|
|
||||||
|
```
|
||||||
|
schedulerLoop(唯一持有任务状态与帧)
|
||||||
|
for {
|
||||||
|
if 可切换 && preemptionRequest 有效 → 执行抢占(保存现场)
|
||||||
|
if running == nil → pick from 三集合;无候选则等待 inbox
|
||||||
|
runOneStep(running) // 可能是阻塞调用(见 §8.2)
|
||||||
|
处理 step 结果 → 推进或结束任务
|
||||||
|
}
|
||||||
|
|
||||||
|
interruptLoop(不持有任何帧)
|
||||||
|
收 interruptCh → 定级 → 决策 → 置 preemptionRequest + cancel + wake scheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.1 不变量
|
||||||
|
|
||||||
|
| # | 不变量 |
|
||||||
|
|---|---|
|
||||||
|
| I1 | 任意时刻至多一个 `running` 任务("一个 running"约束的是**副作用**,不只是 CPU) |
|
||||||
|
| I2 | 任务帧只由 `schedulerLoop` 读写;`interruptLoop` 只写 `preemptionRequest` / 读 `stepCancel` |
|
||||||
|
| I3 | 任何跨挂起点的状态都是纯数据,不持有锁 |
|
||||||
|
| I4 | 安全点只在 step 边界;工具执行中与 COMMIT 不是安全点 |
|
||||||
|
| I5 | 每个任务恰好一次终态事件(含 `responseCh` 写入) |
|
||||||
|
| I6 | 调度器本身永不退出(panic 只使当前任务失败) |
|
||||||
|
|
||||||
|
### 8.2 关于"调度器不被阻塞"(**待确认 D2**)
|
||||||
|
|
||||||
|
> **M3 拆分的理由**:真正的挂起要求帧跨越 `prepare → step… → finish` 全生命周期。
|
||||||
|
> 若只把 `process()` 改成可挂起,`processInput` 会在挂起返回后继续执行
|
||||||
|
> `context.Append` 与 `emitResponse`——造成重复提交。故 M3 分为 M3a(所有权重构,
|
||||||
|
> 行为等价)与 M3b(抢占语义)两步。
|
||||||
|
|
||||||
|
v1 采纳:**`S_TOOL_EXEC` / ONNX / CAS 属于临界区,调度器在这些 step 上会阻塞进插件 RPC / 原生调用。** 这是有意的取舍:
|
||||||
|
|
||||||
|
- 好处:与"两个 goroutine 就够"一致,实现简单,无临时 goroutine。
|
||||||
|
- 代价:这些临界区期间**中断只能排队,不能抢占**。换言之,**中断的有效窗口 = `S_LLM`**(与今天的实际行为相同,但现在是显式声明而非隐式结果)。
|
||||||
|
- 演进(v2):把 `S_TOOL_EXEC` 改成异步 step(临时 goroutine + 完成事件),并给插件协议加 `tool.cancel`。此路径在文档保留,不在 v1 实现。
|
||||||
|
|
||||||
|
### 8.3 panic 隔离与 panic 中断
|
||||||
|
|
||||||
|
- `runOneStep` 外包 `recover`:panic → 当前任务标记 `failed`,**调度器继续**。
|
||||||
|
- panic 同时**产生一条内核 L4 中断**(`reportTaskPanic` → `raiseKernelInterrupt`):
|
||||||
|
内核把自己发生了 panic 这件事作为最高级中断通知给调度器,让 agent 能知情/善后。
|
||||||
|
- 递归保护是**结构性**的:若 panic 的任务本身就是 L4 内核中断,不再产生新的 L4——
|
||||||
|
否则同一个 panic 会自我放大成中断风暴。
|
||||||
|
- 取代现有 `eventLoop`/`interceptLoop` 的 `recover → sleep 1s → go loop()` 无退避重启(`eventloop.go:19-22,38-42`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 失效模式与防御
|
||||||
|
|
||||||
|
| 失效 | 防御 |
|
||||||
|
|---|---|
|
||||||
|
| 饥饿(高优先级流反复抢占) | `preemptCount` 提升有效级:`effectiveLevel = min(4, baseLevel + min(preemptCount, 2))`;被抢占 +1。**只对中断生效**——排队任务无级别,按定义可被任何中断打断 |
|
||||||
|
| 无界下潜 | 中断栈帧数上界 4(结构推论 = 中断级数);超限只做防御性计数,**不降级不丢帧** |
|
||||||
|
| 中断请求堆积 | 中断队列合计有界,满则丢最低级别里最老的一条并计数 |
|
||||||
|
| 就绪队列满 | 阻塞发送方 + 计数(不静默丢) |
|
||||||
|
| 同一任务反复被打断 | `preemptCount` 达阈值后有效级提升;另设**抢占冷却**:刚被抢占的任务在 `cooldown` 内不再被同级/更低级抢占 |
|
||||||
|
| 任务永不结束 | 每任务 `maxTurns`(主循环目前缺失,见审查 P0)+ 每步超时 |
|
||||||
|
| 不可观测 | `Scheduler.Dump()` 原子快照 + 事件(切换原因、降级次数、丢弃次数) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 非目标(v1 明确不做)
|
||||||
|
|
||||||
|
1. 工具级取消 / 可抢占工具(`tool.cancel`)。
|
||||||
|
2. 多 agent 并行(仍是单 agent 单调度器)。
|
||||||
|
3. 公开 SDK 接口变更。
|
||||||
|
4. 微抢占(任意指令级)。
|
||||||
|
5. 跨进程恢复(帧不落盘)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 测试点、测试方式与预期结果
|
||||||
|
|
||||||
|
测试基础设施(先于 M1 落地):
|
||||||
|
|
||||||
|
- **假时钟** `Clock` 接口(`Now()` / `AfterFunc`),生产用真实实现,测试注入可控时钟。
|
||||||
|
- **假 Provider**:实现 `agentAPI.Provider`,返回脚本化的 `tool_calls` 序列(支持"第 N 次调用时挂起直到放行")。
|
||||||
|
- **假工具**:测试内 `StageHost.RegisterTool` 注册,可控制每次执行耗时、是否返回错误、是否触发中断注入。
|
||||||
|
- **同步栅栏**:测试通过 `scheduler.Inbox` 注入中断并用 `runtime.Gosched` + 显式 `waitFor(state)` 断言,不用 sleep 猜时序。
|
||||||
|
- **快照断言**:`scheduler.Dump()` 返回 `{running, queue, interruptQueues[1..4], immediate, suspendStack, counters}`,测试对纯数据断言。
|
||||||
|
|
||||||
|
### 11.1 优先级与抢占
|
||||||
|
|
||||||
|
| 编号 | 测试点 | 方式 | 预期结果 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P1 | 更高中断抢占中断 | running=L2 在 `S_LLM`;注入 L3 中断 | L2 压入中断栈(step=S_LLM);L3 进 `immediate` 并变 running |
|
||||||
|
| P2 | 相等级别不抢占 | running=L2 中断在 `S_LLM`;注入 L2 | 不抢占;请求入 L2 中断队列;running 不变 |
|
||||||
|
| P3 | 更低级别不抢占 | running=L3 中断;注入 L2 | 同上,不抢占 |
|
||||||
|
| P4 | 逐级抢占嵌套 | 排队任务 → L1 → L2 → L3 → L4,均在 `S_LLM` | 中断栈深度依次 1/2/3/4;每层 step 均为 S_LLM |
|
||||||
|
| P5 | 抢占后在安全点才生效 | running=排队任务在 `S_TOOL_EXEC`;注入 L4 | 抢占**不立即生效**;工具返回后才保存/切换;`deferredPreemptions==1` |
|
||||||
|
| P6 | 临界区不可抢占 | running 声明临界区;注入 L4 | 同上;L4 请求留在中断队列,临界区结束立即被选中 |
|
||||||
|
| **P7** | **排队任务被任何中断打断** | running=排队任务;注入 **L1** 中断 | L1 也抢占成功(排队任务有效级 0) |
|
||||||
|
| **P8** | **排队输入永不抢占** | running=任意任务;注入排队输入 | 不抢占,入排队队列 |
|
||||||
|
| **P9** | **外部插件不能声明 L4** | 外部来源声明 `Priority="L4"` | 被夹到 L3(proc 桥 + core 双重) |
|
||||||
|
| **P11** | **内核级插件可用 L4** | 内置插件(如 webui)声明 `L4` | 得到 L4 并立即打断当前任务(终止按钮) |
|
||||||
|
| **P10** | **panic 产生 L4 中断** | 任务 panic | 产生一条带 `kernel=true` 的 L4 中断;L4 自身 panic 不再递归 |
|
||||||
|
|
||||||
|
### 11.2 保存现场与恢复
|
||||||
|
|
||||||
|
| 编号 | 测试点 | 方式 | 预期结果 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| R1 | 在 `S_LLM` 抢占后恢复 | 构造 A 在 `S_LLM` 被 B 抢占;B 结束 | A 恢复后**重新发起** LLM 请求;`msgs` 与 A 被抢占前**逐字节相同**;不重复执行已完成的工具 |
|
||||||
|
| R2 | 在 `S_TOOL_BEGIN` 抢占后恢复 | A 完成 1 个工具批后于 `S_TOOL_BEGIN` 被抢占 | A 恢复后继续**下一批**工具;`toolResults` 长度不变 |
|
||||||
|
| R3 | 恢复结果与不中断一致 | 同一脚本跑两次:一次中途注入中断,一次不注入 | 两次最终 `context` 事件序列**除"中断任务自身的事件"外一致**;A 的 `toolsUsed` 顺序相同 |
|
||||||
|
| R4 | 嵌套恢复顺序 | L4→L3→L2 依次抢占后依次结束 | 按有效级/到达序恢复;每个任务的 `frame.step` 与其被挂起时一致 |
|
||||||
|
| R5 | 不完整的 LLM 请求被丢弃 | 假 Provider 在流式途中触发中断 | 该次请求被 cancel;**不产生任何 `msgs` 追加、不产生 tool_call**;恢复后重发次数 = 1 |
|
||||||
|
|
||||||
|
### 11.3 回执路由
|
||||||
|
|
||||||
|
| 编号 | 测试点 | 方式 | 预期结果 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| X1 | 任务级回执不误投 | A 为同步任务并已挂起;B 为同步中断 | B 的回执只到 `B.responseCh`;`A.responseCh` 在 A 恢复并结束后才收到自己的回执 |
|
||||||
|
| X2 | 断链路径必有终态 | 分别构造:解析失败、10s 去重命中、`_consolidation_` | 三种都产生 `skipped` 终态事件并回执;同步调用方**不挂起** |
|
||||||
|
| X3 | 每任务恰一次终态 | 统计 `S_FINISH` 次数 vs 任务数 | 相等(I5),无重复写入 |
|
||||||
|
| X4 | 无超时同步注入不再永久挂起 | `cli` 路径(无超时)注入一条会被去重的输入 | 返回 `skipped` 回执而非永久阻塞 |
|
||||||
|
|
||||||
|
### 11.4 队列与选择
|
||||||
|
|
||||||
|
| 编号 | 测试点 | 方式 | 预期结果 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Q1 | 中断队列按级别扫 | 四条中断队列各放一个,入队顺序与级别相反 | 取出顺序 L4→L3→L2→L1;中断耗尽后才是排队任务(FIFO) |
|
||||||
|
| Q2 | 挂起现场优先于新排队工作 | A 被抢占挂起 + B 为新排队输入 | A(栈顶)先被选中 |
|
||||||
|
| Q3 | 栈顶 vs 中断队头 | 栈顶 L3 + 队头 L2 / 栈顶 L3 + 队头 L4 / 栈顶为排队任务 + 队头 L1 | 分别取 栈顶 / 队头 / 队头 |
|
||||||
|
| Q4 | 就绪队列背压 | readyQueue 满后注入排队输入 | 发送方阻塞 + 计数 +1;不静默丢弃 |
|
||||||
|
| Q5 | 中断队列溢出 | 中断队列合计满后注入更多 | 丢**最低级别里最老**的一条 + 计数;其余保持 |
|
||||||
|
| **Q6** | **immediate 最优先** | `immediate` 非空且中断队列里有更高级别 | 取 `immediate`(抢占必须立即生效) |
|
||||||
|
|
||||||
|
### 11.5 深度、饥饿与并发
|
||||||
|
|
||||||
|
| 编号 | 测试点 | 方式 | 预期结果 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D1T | 下潜深度上界(结构推论) | 挂起 3 帧后继续注入;再挂起到 4 帧 | 3 帧时 `canSuspend()==true`;4 帧(全链:排队+L1+L2+L3,L4 运行中)时为 `false` |
|
||||||
|
| G1 | 饥饿防护(抢占提升) | 对同一 **L1 中断**连续抢占 5 次(同级/高级交替) | `effectiveLevel` 提升至 `min(4, 1+2)=3`;第 3 次后不再被 L1/L2 抢占 |
|
||||||
|
| G2 | 冷却生效 | 同一中断刚被抢占后立刻再注入同级中断 | 冷却期内不抢占,请求入中断队列 |
|
||||||
|
| **G3** | **提升也必须只在中断间生效** | 排队任务被连续抢占 | 排队任务有效级恒 0(不被提升;它按定义可被任何中断打断) |
|
||||||
|
| K1 | panic 隔离 | 假工具 panic | 只有该任务变 `failed`;调度器存活;后续任务正常执行 |
|
||||||
|
| K2 | 竞态检查 | 全部调度用例加 `-race` | 无数据竞争报告 |
|
||||||
|
| O1 | 快照一致性 | 在任意 step 边界调 `Dump()` | 返回的 `running/ready/pending/suspend` 三集合互不重叠且总数守恒 |
|
||||||
|
| O2 | 切换可观测 | 每次抢占/恢复 | 产生一条事件(任务 id、原因、from→to、level) |
|
||||||
|
|
||||||
|
### 11.6 端到端
|
||||||
|
|
||||||
|
| 编号 | 测试点 | 方式 | 预期结果 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| E1 | 真实 provider + 假长工具 | 启动内核,用一个会阻塞 5s 的假工具跑 L1 任务,途中经 `interceptCh` 注入 L4 中断 | 中断**在工具执行期间不被处理**;工具返回后立即抢占;中断任务先完成;原任务恢复并完成 |
|
||||||
|
| E2 | LLM 流式中断 | 假 Provider 慢速流式返回 | 中断后当前流被 cancel,任务挂起,中断任务完成,原任务恢复并重新请求 |
|
||||||
|
| E3 | 现有 e2e 回归 | 跑 `internal/plugins/integration_test.go`、`real_plugin_smoke_test.go` | 行为不变(除文档化的语义变化) |
|
||||||
|
| **E4** | **优先级压力(用户指定形状)** | 固定内容假 provider(**记延迟,且被取消时立刻返回**),100 条排队输入 + 100 条中断(L1/L2/L3/L4 各 25)混合打入;每条中断都等到“该被它打断的受害者正在跑”时才注入 | 200 个任务全部到达终态;`Rejected=0`;各级登记数 = 25;**各级抢占数都 > 0**;排空后 `Suspended == Resumed`;每次“取消流式段”都换来一次挂起 |
|
||||||
|
| **E5** | **嵌套到结构上限并 LIFO 展开** | 排队任务运行中依次注入 L1→L2→L3→L4(每级都等上一级在跑) | 栈深峰值恰好 **4**(= 结构上限,`canSuspend()==false`);恢复顺序严格 LIFO `[L3, L2, L1, 排队]`;`Suspended==Resumed==4` |
|
||||||
|
|
||||||
|
> E4/E5 的 provider 必须**感知 ctx 取消**:否则抢占只能等任务自然结束,
|
||||||
|
> 测到的全是"步骤之间让位",流式段的取消路径(真正的现场保存/恢复)压不到。
|
||||||
|
> 实测:不感知取消时 `LLM完成 == 任务数`、挂起接近 0;感知后取消次数与挂起次数一一对应。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 待确认决策(含默认取值)
|
||||||
|
|
||||||
|
> 未获异议时按"默认取值"实现;每项单独一个 commit,便于回退。
|
||||||
|
|
||||||
|
| 编号 | 问题 | 默认取值 |
|
||||||
|
|---|---|---|
|
||||||
|
| **D1** | 中断任务的上下文 | **方案 B(已定)**:中断从上一个任务之前的完整状态开始;恢复时把被挂起任务的现场加载回中断之上 |
|
||||||
|
| **D2** | 阻塞 step 处置:v1 全部声明为临界区(调度器可被阻塞)还是引入异步 step | **v1 = 临界区**;异步 step 留到 v2 |
|
||||||
|
| **D3** | 中断队列与排队队列是否合一 | **完全分离**:中断按级别分四条队列(L4→L1 扫描),排队队列纯 FIFO,两者不共用比较器 |
|
||||||
|
| **D7** | 任务类别怎么定 | **由注入 API 决定**(`InjectInterrupt*` = 中断;`InjectText*`/`InjectInputSync*`/自循环 = 排队),**不按通道名推断** |
|
||||||
|
| **D8** | L4 归谁 | **内核独占**。唯一入口 `(*Agent).raiseKernelInterrupt`(panic / selfip);`clampPluginLevel` 把插件声明夹到 L3 |
|
||||||
|
| **D9** | L1–L3 归谁 | **插件在 `InjectOptions.Priority` 里声明**(纯追加字段);空/非法降级到 L1 |
|
||||||
|
| **D10** | 抢占者进入队列还是立即运行 | **立即运行**(`immediate` 槽)。这消除“抢占者与被挂起者同级”的比较,删除了早期的“同级 pending 优先”补丁 |
|
||||||
|
| **D11** | 中断栈帧数上界 | **结构推论 = 4**(排队 L0 + I1 + I2 + I3 挂起,I4 运行中),不是配置项;超限只计防御性计数 |
|
||||||
|
| **D4** | readyQueue 满时:阻塞发送方 or 返回错误 | **阻塞发送方 + 计数**(与现状一致,避免丢用户输入) |
|
||||||
|
| **D5** | 饥饿防护:抢占计数提升 or 时间老化 | **抢占计数提升**(确定性、易测);时间老化留待需要时 |
|
||||||
|
| **D6** | 主循环 `max_tool_turns` 是否在本特性一并落地 | **是**(审查 P0,且调度器需要"任务可终止"这一前提) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 与发布纪律的关系
|
||||||
|
|
||||||
|
- 本特性在 `feature/input-semantics` 上开发,完成后合回 `main`,**不碰 `release/v1.2.x`**。
|
||||||
|
- **公开 SDK 在本特性上有意新增**(feature 分支不受 rel 分支的接口冻结约束):
|
||||||
|
`sdk.InjectOptions.Priority` 与 `sdk.PriorityL1/L2/L3`。这是为了让插件能声明
|
||||||
|
自己中断的级别(§3.2)。
|
||||||
|
- **追加是唯一的形态**:不改既有字段、不改签名、不改语义;`Priority` 的零值
|
||||||
|
等价于旧行为(L1)。
|
||||||
|
- 合回 `main` 前需完成的发布动作:
|
||||||
|
1. 同步更新 `docs/zh/plugin-interface-matrix.md`;
|
||||||
|
2. 与 SDK 仓协同升 SDK 中版本;
|
||||||
|
3. 遵守“只增不减、签名不改”边界。
|
||||||
|
- 内核侧接口(`internal/agent/io`、proc 桥的 `injectParams`/`injectMediaParams`)
|
||||||
|
同步追加 `priority`,与公开 SDK 字段一一对应。
|
||||||
|
|
||||||
|
## 14. 实现里程碑(逐个实现,每个 = 一个可独立验收的提交)
|
||||||
|
|
||||||
|
| 里程碑 | 内容 | 验收 |
|
||||||
|
|---|---|---|
|
||||||
|
| **M0** | 测试基础设施:`Clock` 接口、假 Provider、假工具、`waitFor`、`Dump()` 骨架 | 新测试可运行;`go vet` 干净 |
|
||||||
|
| **M1** | **纯重构**:把 `process()` 拆成显式 step 状态机 + `TaskFrame`;仍由现有 `eventLoop` 驱动,无优先级/无抢占 | R3、X3 通过;既有全部 agent 测试通过(行为等价) |
|
||||||
|
| **M2** | 调度器骨架:单 `schedulerLoop` + `readyQueue`,取代 `eventLoop` 的输入处理;无优先级(全部 L1,纯 FIFO) | Q1/Q4 通过;integration 测试通过 |
|
||||||
|
| **M3a** | **前置重构(本次拆分引入)**:把一轮对话的所有权从 `processInput` 移到调度器——帧覆盖 `prepare → step… → finish`;同时移除 `process()` 整轮持有的 `a.mu`(挂起不能持锁) | 既有全部 agent 测试 + 既有 e2e 通过(行为等价);`-race` 干净 |
|
||||||
|
| **M3b** | `interruptLoop` 重写 + 四级优先级 + 严格大于抢占 + 中断栈 LIFO;只支持 `S_LLM` 抢占 | P1–P4、R1、R5、K1–K2 通过;嵌套 LIFO 判据通过 |
|
||||||
|
| **M4** | 临界区 + `S_TOOL_EXEC` 声明 + 中断队列 + 深度上界(**后经模型更正重做,见下**) | P5–P6、D1T、Q3、Q5 通过 |
|
||||||
|
| **M5** | 饥饿防护(抢占计数提升 + 冷却) | G1–G2 通过 |
|
||||||
|
| **M6** | 任务级 `responseCh` + 断链点统一为终态事件 | X1–X4 通过;`cli`/`clawhub` 不再挂起 |
|
||||||
|
| **M7** | 可观测性(`Dump()`/事件/状态页)+ 既有回归 | O1–O2、E1–E3 通过;`go test -race ./internal/agent/... ./internal/plugin/...` 全绿 |
|
||||||
|
|
||||||
|
每步收尾命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GOCACHE=/tmp/gocache GOPATH=/tmp/gopath
|
||||||
|
gofmt -l internal/agent internal/plugin internal/sdk # 本步新增文件必须为空
|
||||||
|
go build ./... && go vet ./...
|
||||||
|
go test -race -count=1 ./internal/agent/... ./internal/plugin/... ./internal/sdk/...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 实现状态(2026-09-13 完成)
|
||||||
|
|
||||||
|
| 里程碑 | 提交 | 验收结果 |
|
||||||
|
|---|---|---|
|
||||||
|
| M1 | `9a58878` | ✅ agent 全量 + `-race`;新增 `task_test.go` 4 项 |
|
||||||
|
| M2 | `7082a50` | ✅ 新增 `scheduler_test.go` 6 组(含 O1/K1) |
|
||||||
|
| M3a+M3b | `c69a1f1` | ✅ 新增 `task_lifecycle_test.go` 5 项、`scheduler_preempt_test.go` 5 项 |
|
||||||
|
| M4 | `7565248` | ✅ 新增 `scheduler_critical_test.go` 3 项 |
|
||||||
|
| M5 | `a971fc8` | ✅ 新增 `scheduler_starvation_test.go` 4 项 |
|
||||||
|
| M6 | `4e4e0ad` | ✅ 新增 `task_terminal_test.go` 3 项 |
|
||||||
|
| M7 | `f11de37` | ✅ 新增 `scheduler_e2e_test.go` 3 项(压力/可观测/端到端) |
|
||||||
|
|
||||||
|
#### 模型更正后的重构(2026-09-13,同一特性分支)
|
||||||
|
|
||||||
|
用户逐条澄清后重做调度核心(**行为有意的语义变化**,非等价重构):
|
||||||
|
|
||||||
|
| 项 | 内容 | 验收 |
|
||||||
|
|---|---|---|
|
||||||
|
| 类别化 | `TaskClass{queued,interrupt}`;类别由注入 API 决定;`newInputTask`/`newSelfTask` 为 queued,`newInterruptTask` 为 interrupt | `scheduler_kernel_test.go` P7/P8 |
|
||||||
|
| 级别归位 | `Level` 语义改为“中断级别”;`taskLevel()`(按通道名推断)删除,改为 `interruptLevel(evt, privileged)` 读 `payload["priority"]` | P9、P11、Q1 |
|
||||||
|
| L4 内核独占 | `raiseKernelInterrupt`(panic/selfip);`requestKernelPreempt` 不夹取;panic 报告为 L4 且带递归保护 | P10、`TestKernel_PanicRaisesL4Interrupt` |
|
||||||
|
| 选择结构 | `immediate` + 四条中断队列 + 排队 FIFO + 中断栈;删除统一比较器 `pickTaskIndex`/`taskBefore` 与“同级 pending 优先”补丁 | Q1–Q3、Q6 |
|
||||||
|
| 栈上界 | `maxSuspendDepth`(配置语义)→ `maxInterruptFrames = int(LevelCritical)`(结构推论);删除“超限转 pending”降级 | D1T |
|
||||||
|
| 公开 SDK | `InjectOptions.Priority` + `PriorityL1..L4`;io/proc 桥/插件模板同步透传;`example/qq` 声明 L1、`timer` 声明 L3、`webui` 终止按钮声明 L4 | `go test ./...` 全绿 |
|
||||||
|
| 分级可观测 | `SchedulerStats.InterruptsByLevel[1..4]` / `PreemptsByLevel[1..4]`(按级别分桶,见 §11.6 E4) | 压力测试按级别断言 |
|
||||||
|
| 计数修正 | `Resumed` 原本在 `nextRef` 与 `resumeTask` **各计一次**(双计),使"排空后 Suspended==Resumed"失真;现只在 `resumeTask` 计 | E4 断言 |
|
||||||
|
| 压力测试 | `scheduler_stress_test.go`:100 排队 + 100 中断(各级 25)混合;另加嵌套到 4 帧上限并验证 LIFO | E4/E5 |
|
||||||
|
|
||||||
|
实现期与设计的差异(均已回写本文档):
|
||||||
|
|
||||||
|
1. **M3 拆为 M3a/M3b**:真正挂起要求帧跨 `prepare→run→finish`,否则 `processInput`
|
||||||
|
会在挂起返回后继续提交。
|
||||||
|
2. **`a.mu` 整体移除**:它原本只包住整轮 `process()`(同一 goroutine),
|
||||||
|
移除后所有任务状态由 schedulerLoop 独占(不变量 I2/I3 可落地)。
|
||||||
|
3. **`interceptCh` 被删除**:M3b 起中断一律走中断队列(当时叫 `pendingInterrupts`),旧的
|
||||||
|
“同行注入 + 三处 drain + 批次放弃” 已无写入者,属死代码(M4 清理)。
|
||||||
|
4. **v1 未做 M0 的伪时钟**:所有抢占测试用“单次调用阻塞到 ctx 取消”的
|
||||||
|
provider 达到确定性,无需注入时钟。时序型判据(老化式提升)留待需要时。
|
||||||
|
5. **工具执行中不可抢占是被结构保证的**:让位检查只在 step 之间;
|
||||||
|
不需要在 step 内部再判一次。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 开放问题(后续版本)
|
||||||
|
|
||||||
|
1. 异步 step + `tool.cancel`(真正让工具可抢占)。
|
||||||
|
2. 帧落盘(跨进程/崩溃恢复)。
|
||||||
|
3. 多 agent 并行调度。
|
||||||
|
4. 与 `plan.md` §13.7 的 `RuntimeManager + 分组 worker` 合并(本设计是其前置)。
|
||||||
|
|
||||||
|
> **已更正**:早期稿写“`InjectOptions.Priority` 进入公开 SDK 已被删除”,
|
||||||
|
> 前提是“优先级是内核内部属性、不应由插件声明”。用户澄清后该前提被推翻:
|
||||||
|
> **L1–L3 就是给插件声明使用的**。L4 的归属后来也明确了——不是“只有
|
||||||
|
> panic/selfip”,而是**内核 + 内核级插件**(编译期内置)都能用,用于实现
|
||||||
|
> “立即打断”(panic、内核事件、WebUI 终止按钮)。因此公开 SDK 同时导出了
|
||||||
|
> `PriorityL4`(附“仅内核级插件”的说明)。
|
||||||
|
>
|
||||||
|
> 仍**不做**的是“运维可调的策略表”(`core.agent.priority.<channel>`)——
|
||||||
|
> 那是把调度内部属性外化成配置,与“由调用方声明自己那件事有多不能等”不同。
|
||||||
603
docs/zh/resident-subagent-design.md
Normal file
603
docs/zh/resident-subagent-design.md
Normal file
@ -0,0 +1,603 @@
|
|||||||
|
# 驻留式子 Agent 设计(轻量内核 · 两级记忆 · 父子中断)
|
||||||
|
|
||||||
|
> **前置**:本文建立在《输入调度器设计》(`docs/zh/input-scheduler-design.md`)之上。
|
||||||
|
> 那里已经落地了:两类别输入(中断 / 排队)、四级中断优先级、可抢占、
|
||||||
|
> 现场保存/恢复、中断栈(LIFO,结构上界 4 帧)、任务级回执、panic→L4。
|
||||||
|
> 本文只描述**驻留式子 agent** 这一新能力,以及它对既有实现的改动。
|
||||||
|
>
|
||||||
|
> 标记:**[已定]**= 用户明确拍板;**[默认]**= 本文给出的可逆默认取值,实现时在提交信息里标注。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
现状的子 agent 是**工作式轻量子**:`spawn_child` 起一个一次性 goroutine(临时 `msgs`),
|
||||||
|
跑完把结果经 `selfInputCh` 回投父,然后销毁。它**没有**自己的中断机制、没有通道分配、
|
||||||
|
没有可查询的状态面,父也无法在它运行途中干预它。
|
||||||
|
|
||||||
|
要让父 agent 能**长期派驻**一个下属去持续处理某类工作(一个 inputch 上的来源、一段长期目标),
|
||||||
|
就需要一种新的子 agent:**驻留子**。它必须满足:
|
||||||
|
|
||||||
|
- 有自己的**轻量内核**(自己的调度器、中断机制、上下文),所以父能"打断它"、"查它"、"回收它";
|
||||||
|
- 有自己的**记忆空间**(可写),但**改不了父的主记忆** —— 记忆入库的决策权留在父手里;
|
||||||
|
- 与父之间有**双向、可寻址**的通道(子→父、父→指定子),且父对子的消息是**最高级中断**;
|
||||||
|
- 有一张**可被父查询的状态面**(inputch 处理表),使父不打断它也能知道它做到哪;
|
||||||
|
- 生命周期由父掌握:父可随时**销毁**它,父退出时**必须**销毁全部。
|
||||||
|
|
||||||
|
**两类子 agent 并存**[已定]:工作式轻量子**原样保留**(它是"内核自循环"的一部分,
|
||||||
|
不是对等 agent);驻留子是新增的第二类。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 术语
|
||||||
|
|
||||||
|
| 术语 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| **根 agent** | 进程级主 agent,拥有**完整内核**(含记忆读写、全部通道) |
|
||||||
|
| **驻留子** | 父创建、长期驻留的子 agent,拥有**轻量内核**与**临时记忆空间** |
|
||||||
|
| **工作式轻量子** | 现状 `spawn_child` 的一次性子任务(**不是内核实例**) |
|
||||||
|
| **inputch** | **最基本的输入路由单位**:对"中断输入 / 排队输入"两者的高层抽象,是**路由与分配**的单位;**由插件注册,一个插件可注册多个** |
|
||||||
|
| **outputch** | 输出通道;输出是 agent 的**主动调用**,并**可寻址到具体 agent** |
|
||||||
|
| **main 空间** | 主记忆空间:父读写、所有子只读、全部子共享 |
|
||||||
|
| **temp 空间** | 子临时记忆空间:该子读写、子之间互不可见 |
|
||||||
|
| **状态面** | 子对外(对父)可查询的状态:含 **inputch 处理表**与产出 |
|
||||||
|
| **处理表** | 按 inputch 记录每轮处理信息的状态表(子持有,父 pull) |
|
||||||
|
| **登记表** | 父持有的全部子 agent 名录(id / 状态 / 通道 / 状态面句柄) |
|
||||||
|
| **contextfull** | 子的上下文窗口满,产生 L4 中断通知父 |
|
||||||
|
| **压缩 / 回收 / 销毁** | 父对 contextfull 的三种处置(见 §9) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 角色与内核形态
|
||||||
|
|
||||||
|
| | **根 agent** | **驻留子** | **工作式轻量子**(保留不动) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 内核 | 完整 | **轻量** | 无(不是内核实例) |
|
||||||
|
| 调度器 / 四级中断 / 中断栈 | ✅ | ✅(**L4 只来自父**) | ❌ |
|
||||||
|
| 上下文 | 完整(记忆介导) | 传统上下文(消息序列) | 临时 `msgs` |
|
||||||
|
| 记忆 | main 读写 | **读 temp ∪ main,写 temp** | 无 |
|
||||||
|
| 通道 | 全部 | 父**划入**的输入通道 + **授权**的输出通道 | 无(结果回投父) |
|
||||||
|
| 插件与工具 | 全部 | **父授权,默认完整授权**[已定] | 现有剔除规则不变 |
|
||||||
|
| 状态面 / 处理表 | — | ✅(父 pull) | ❌ |
|
||||||
|
| 子→父 | — | 主动消息 = **L3 中断** | `injectSelfChannel` → `selfInputCh`(排队) |
|
||||||
|
| 主→子 | — | **L4 中断**(取消当前状态 + 插入新消息) | 无 |
|
||||||
|
| 生命周期 | 进程级 | 父持登记表;父可随时销毁;**父退出必须全部销毁** | 跑完即销毁 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 通道模型
|
||||||
|
|
||||||
|
### 4.1 `inputch` 的定义 [已定]
|
||||||
|
|
||||||
|
`inputch` **不是**通道名标签,也**不是**与 outputch 配对的东西。它是:
|
||||||
|
|
||||||
|
1. **最基本的输入路由单位** —— 路由粒度到此为止:比"插件"细、比"通道名字符串"实;
|
||||||
|
2. **由插件注册,且一个插件可注册多个** —— 同一个插件的多个 inputch 是**彼此独立**的
|
||||||
|
路由单位(可以绑给不同 agent、可以分别授权)。登记接口即现有的
|
||||||
|
`RegisterInputChannel(name, def)`,调 N 次就是 N 个 inputch;
|
||||||
|
3. **对"中断输入"与"排队输入"两者的高层抽象** —— 两类输入都从 inputch 进出;
|
||||||
|
4. **路由与分配的单位**:
|
||||||
|
- **路由**:一条输入投给哪个 inputch,就是"该由谁处理"的**既定事实**
|
||||||
|
⇒ **路由发生在进内核之前**;
|
||||||
|
- **分配**:inputch 是**可分配资源**(父把输入通道划给子)。
|
||||||
|
|
||||||
|
「中断 vs 排队」是**每条输入自己的类别**(由注入 API 决定),级别(L1–L4)也是
|
||||||
|
**每条输入的属性**,都不是 inputch 的属性。
|
||||||
|
|
||||||
|
### 4.2 三个入场(沿用现有实现)
|
||||||
|
|
||||||
|
| 入口 | 承载 |
|
||||||
|
|---|---|
|
||||||
|
| `inputCh` | 外部客户端 / 插件的**排队与中断**输入(已预寻址) |
|
||||||
|
| `interruptCh` | 中断入站(外部 / 插件 / 内核自身) |
|
||||||
|
| `selfInputCh` | **内核自循环**:consolidation、工作式轻量子回投 |
|
||||||
|
|
||||||
|
### 4.3 输出是**主动调用** [已定]
|
||||||
|
|
||||||
|
- 异步通道(qq / 微信 / 群聊…):必须显式调用 `output_send__{通道名}` 才真正送达;
|
||||||
|
- 同步通道(webui / cli / 终端):返回纯文本,内核把文本交给等待方 ——
|
||||||
|
走输入事件自带的 `ResponseCh`,这是**事前定好的回程**。
|
||||||
|
|
||||||
|
⇒ **内核不持有"当前通道"可变状态**,**提示词也不预设 outputch**。
|
||||||
|
(要删 `Agent.currentOutputChannel`;要删 `tooldefs.go` 里"当前输入来源通道是 X,
|
||||||
|
对应输出门工具是 output_send__X"那两行。)
|
||||||
|
|
||||||
|
> 之前把这件事说成"内核路由"是错的:内核只负责**投递到既定的回程**与**执行显式的输出调用**。
|
||||||
|
|
||||||
|
### 4.4 通道分配:不对称 [已定]
|
||||||
|
|
||||||
|
- **不按对划分**。父为子:**划入若干 inputch**(单位是 inputch,可以来自同一个插件的不同 inputch)
|
||||||
|
+ 授权**一组可用输出通道**(授权集合,不是一对一)。
|
||||||
|
- **输出通道可寻址到具体 agent**:
|
||||||
|
- **子 → 主**:子直接打到主(经输出通道投进主的 inputch);
|
||||||
|
- **主 → 指定某个子**:父经输出通道投进**指定子**的 inputch。
|
||||||
|
|
||||||
|
### 4.5 已经落地/待落地的两件事
|
||||||
|
|
||||||
|
**已落地(N1a)**:inputch 登记表(归属插件 / 归属 agent / 容量 / 默认回程 / 策略)+ 共享登记表
|
||||||
|
+ 单工具多视图总览(`input_channels`,见 §4.6)。
|
||||||
|
|
||||||
|
**已落地(N1b)**:
|
||||||
|
- **输出通道授权集合**[已定:默认完整授权,父可收窄]:
|
||||||
|
`AgentConfig.AllowedOutputs`(nil/空 = 全部)。三处过滤点必须一致,
|
||||||
|
否则会出现"列表里看不到、按名字还能调"的裂缝:
|
||||||
|
1. **工具表**:不为未授权的通道生成 `output_send__X`(模型看不到就不会调);
|
||||||
|
2. **列表工具**:`output_list_channels` 只列授权的(已登记目标的会标出"目标: agent / inputch");
|
||||||
|
3. **调用点**:凭名字直调未授权的输出门**必须被拒**(纵深防御)。
|
||||||
|
- **输出通道 → 目标 agent 的 inputch 解析**:`ChannelRegistry.BindOutputTarget` /
|
||||||
|
`ResolveOutputTarget`(未登记的通道由传输层 device 自行处理,如 qq/webui)。
|
||||||
|
这是"输出可寻址到具体 agent"的数据面;真正的跨 agent 投递在 N4。
|
||||||
|
|
||||||
|
### 4.5.1 通道的一等化(后续要求)
|
||||||
|
|
||||||
|
现在 `Source` / `OutputChannel` 只是字符串标签,`IOManager.inputCh` 是**一条全局 channel**,
|
||||||
|
`inputChannels` 只是策略表(`ChannelDef`:NoMemory / Cleaner / ContextPolicy),
|
||||||
|
**没有归属、没有绑定、没有容量**。要实现 §4.4 需要新增:
|
||||||
|
|
||||||
|
- **通道注册层**:`inputch` 成为一等对象。它已经是**最基本的输入路由单位**,
|
||||||
|
所以注册表以 **inputch 为键**(一个插件 → N 个 inputch),并给每个 inputch 带上:
|
||||||
|
**归属/被划给的 agent · 容量 · 可接收类别 · 输出目标解析**;
|
||||||
|
⇒ 同一个插件的两个 inputch 可以**分别划给不同 agent**、**分别限额**;
|
||||||
|
- **输出通道 → 目标 agent 的 inputch** 的解析;
|
||||||
|
- **授权过滤**:`output_list_channels` 只列该 agent 被授权的通道。
|
||||||
|
|
||||||
|
### 4.6 inputch 总览:**单工具多视图** [已定]
|
||||||
|
|
||||||
|
父 agent 必须能看清两件事:**有哪些 inputch 已注册(谁注册的)**、**它们是怎么划分的**。
|
||||||
|
按用户要求构筑为**单工具多视图**(一个工具 + 一个 `view` 参数),而不是一堆小工具 ——
|
||||||
|
视图切换比工具增殖更好用,也更省提示词预算。
|
||||||
|
|
||||||
|
工具:**`input_channels`**
|
||||||
|
|
||||||
|
| view | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| `all`(默认) | 全部已注册 inputch:名字 / **归属插件** / 归属 agent / 容量 / 记忆策略标记 |
|
||||||
|
| `mine` | 划给**本 agent** 的 |
|
||||||
|
| `unassigned` | **尚未划出**的(可按需分配) |
|
||||||
|
| `by_agent` | **划分情况总览**:按归属 agent 分组列出各自拥有哪些 inputch |
|
||||||
|
| `detail`(需 `name`) | 单个 inputch 的全字段(注册插件 / 归属 / 容量 / 默认回程 / 记忆策略) |
|
||||||
|
|
||||||
|
- 未知 `view` **必须报错并列出可用值**(拼错不得被静默当成默认视图)。
|
||||||
|
- 登记表是**可共享对象**(`*ChannelRegistry`):根 agent 与它的驻留子共用同一份,
|
||||||
|
这样"划入/授权"才有意义(默认每个 agent 自带一份,向后兼容)。
|
||||||
|
- **插件重载不得抹掉划分**:重复登记只更新「归属插件 + 策略」,
|
||||||
|
保留已有的 Owner / Capacity / Output。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 记忆模型:两级空间 [已定]
|
||||||
|
|
||||||
|
> **适用范围**:这套"两级空间"是针对**图记忆**的。子 agent 的记忆面是
|
||||||
|
> **传统上下文 + 图记忆**;**doc 记忆**与 **context 动态上下文**是内核独立设计的
|
||||||
|
> 记忆能力,**只有根 agent 有**(子不可见、不可用,见 §5.5)。
|
||||||
|
|
||||||
|
```
|
||||||
|
主记忆空间(main) ← 子【只读,可看到全部】;父【读写】;所有子共享
|
||||||
|
子临时记忆空间(temp) ← 该子【读写】;每个子独立、互不可见
|
||||||
|
|
||||||
|
子的记忆查询 = temp ∪ main 子的记忆写入 → 只落 temp
|
||||||
|
```
|
||||||
|
|
||||||
|
| | 根 agent | 驻留子 |
|
||||||
|
|---|---|---|
|
||||||
|
| main | **读写** | **只读**(可见全部) |
|
||||||
|
| temp | —(它自己就是 main 的所有者) | **读写**(自己的空间) |
|
||||||
|
| 查询范围 | main | **temp ∪ main** |
|
||||||
|
|
||||||
|
### 5.1 "轻量内核"的真正理由
|
||||||
|
|
||||||
|
不是砍功能,而是**记忆层被作用域化**:
|
||||||
|
|
||||||
|
- **写目标**被限定到 `temp`(子自己的命名空间);
|
||||||
|
- **读视图**被扩成 `temp ∪ main`(两个空间的并集)。
|
||||||
|
|
||||||
|
完整内核的记忆层**硬绑定在单一 main 空间**上。要让每个 agent 都有自己的空间 +
|
||||||
|
并集读视图,记忆层就必须接受**每个 agent 一份的作用域参数** —— 这就是轻量内核存在的理由。
|
||||||
|
|
||||||
|
### 5.2 推论:记忆写工具不禁用,而是**重定向**
|
||||||
|
|
||||||
|
子是**能写**的(写自己的 temp)。因此:
|
||||||
|
|
||||||
|
- 记忆写工具(图记忆写、文本记忆写、**文档记忆写**、**向量索引写**、**媒体落盘**)
|
||||||
|
**不禁用,而是重定向到 temp 命名空间**;
|
||||||
|
- 检索注入算"读",范围 `temp ∪ main`;
|
||||||
|
- 子**不能**写 main ⇒ "哪些内容进入主记忆"的决策**结构上**只在父手里(§9.2)。
|
||||||
|
|
||||||
|
### 5.3 三种处置对记忆的作用
|
||||||
|
|
||||||
|
| 动作 | temp | main | 子 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **压缩** | 保留 | 不写 | 继续 |
|
||||||
|
| **回收** | 父读 → **选中的 promote 进 main** → 丢弃 temp | 父写 | 取消 |
|
||||||
|
| **销毁** | 直接丢弃 | 不写 | 立刻移除 |
|
||||||
|
|
||||||
|
### 5.5 子的记忆面:**传统上下文 + 图记忆** [已定]
|
||||||
|
|
||||||
|
| 记忆能力 | 根 agent | 驻留子 |
|
||||||
|
|---|---|---|
|
||||||
|
| **图记忆**(实体/关系/句子,含其向量检索) | ✅ main 读写 | ✅ **作用域化**(读 temp∪main,写 temp) |
|
||||||
|
| **doc 记忆**(文档记忆) | ✅ | ❌ **不可用**(内核独立设计的记忆能力,父专属) |
|
||||||
|
| **context 动态上下文**(动态上下文装配/裁剪) | ✅ | ❌ **不可用**;子用**传统上下文**(纯消息序列) |
|
||||||
|
| 蒸馏 / 归档 / consolidation | ✅ | ❌(属上述父专属能力) |
|
||||||
|
| 文本记忆 / 知识库 / 媒体 / 社交 | ✅ | ❌(同上) |
|
||||||
|
|
||||||
|
⇒ 所以"轻量内核"的准确表述是:**传统上下文 + 图记忆(作用域化)** ——
|
||||||
|
不是"记忆变轻了",而是**记忆面被裁到只剩图记忆,且图记忆被作用域化**。
|
||||||
|
|
||||||
|
### 5.6 实现形态:**独立存储实例**(不做 space 列)[已定]
|
||||||
|
|
||||||
|
轻量内核的记忆**不是**把共享记忆层加一个 `space` 维度,而是**换一套装配**:
|
||||||
|
|
||||||
|
```
|
||||||
|
子的轻量内核
|
||||||
|
├─ temp 图记忆实例(独立存储,**读写**) ← 子的一切图记忆写入落这里,与子同生共死
|
||||||
|
└─ 主图记忆的**受限句柄**(只读) ← 子只能读
|
||||||
|
· OpenGraphDBReadOnly:连接可读/可恢复 WAL,但 SQLite 层 `PRAGMA query_only=1`
|
||||||
|
把一切写入直接拒掉 —— "子改不了 main" 是**结构性**保证,不靠自觉
|
||||||
|
子的图记忆查询 = temp 实例 与 主实例 各查一次,应用层合并(并集)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **不碰共享记忆层**:不加 `space` 列、不做 schema 迁移、55 处 SQL 原样。
|
||||||
|
- **隔离靠"不同存储实例"**,不靠 where 条件 —— 漏写条件也不会串台。
|
||||||
|
- **回收时由父合入**:父读子的 temp 实例,选出要保留的记录,写进主图记忆(父有写权)。
|
||||||
|
- **可选简化**[用户给的备选]:把"允许子写图记忆"做成 profile 开关
|
||||||
|
(`AllowTempGraphWrite`,默认开)。设为 `false` 时子对图记忆**完全只读**,
|
||||||
|
没有 temp 实例、没有合入 —— 代价是回收时只剩状态面/处理表可收割。
|
||||||
|
|
||||||
|
### 5.4 待钉的边界 [默认]
|
||||||
|
|
||||||
|
- temp 与 main 是**同一套记忆子系统里的命名空间**(同一批表/索引 + 一个 space 维度),
|
||||||
|
不是独立存储 ⇒ "并集读"就是一次查询里的两个 space 条件;
|
||||||
|
- 父**可读**子的 temp(要决定 promote 什么),属"查看状态面"的一部分;
|
||||||
|
- temp **与子同生共死**;子之间 temp **互不可见**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 中断模型:两条独立阶梯 [已定]
|
||||||
|
|
||||||
|
```
|
||||||
|
在【父的】中断阶梯上:
|
||||||
|
子的主动消息 = L3 中断 (子主动汇报,带子标识)
|
||||||
|
子的 contextfull = L4 中断 (资源耗尽,需父立即决策,带子标识)
|
||||||
|
|
||||||
|
在【子的】中断阶梯上:
|
||||||
|
父的消息(发送消息) = L4 中断 ← 子的 L4 归父独占
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.1 L4 归属通则
|
||||||
|
|
||||||
|
> **某个 agent 的 L4 只属于它的"内核"。**
|
||||||
|
|
||||||
|
- 根 agent 的内核 = 内核自身(panic / 内核事件 selfip)+ 内核级插件(WebUI 终止按钮);
|
||||||
|
- 驻留子的内核 = **父 agent**。
|
||||||
|
|
||||||
|
⇒ 现有 `isKernelLevelSource`(只认编译期内置插件)**泛化为"该 agent 的上级"**,不为子开特例。
|
||||||
|
|
||||||
|
### 6.1.1 **父消息 = 子的 L4**(落地机制,钉死)
|
||||||
|
|
||||||
|
父 → 子的"发送消息"是一条 **L4 中断**,它是**子的阶梯上唯一的 L4 来源**。具体落地:
|
||||||
|
|
||||||
|
```
|
||||||
|
父【发送消息】到指定子
|
||||||
|
└─ 经输出通道寻址到该子的某个 inputch
|
||||||
|
└─ 在该子的调度器里按 L4 登记(子的内核级来源 = 父)
|
||||||
|
└─ 子的 L4 > 子内部一切 ⇒ 立即打断子的当前任务
|
||||||
|
```
|
||||||
|
|
||||||
|
- **子内部任何来源都够不到 L4**:子自己的输入注入、工具、定时器、插件……
|
||||||
|
一律被夹到 **≤ L3**(与根 agent 里"外部插件被夹到 L3"完全同一条规则,
|
||||||
|
只是"内核级来源"从"编译期内置插件"变成了"父 agent")。
|
||||||
|
- 后果一:**子内部任何东西都压不过父**(父的话是子的最高级输入)。
|
||||||
|
- 后果二:父的"取消当前状态 + 插入新消息"因此是**确定能生效**的
|
||||||
|
—— 不会因子内部正在跑什么而被挡住(除非子处于不可抢占临界区,
|
||||||
|
此时按调度器既有规则在安全点生效)。
|
||||||
|
|
||||||
|
**子侧内核级事件如何上报**(推论,待确认):子的 panic / contextfull 属于**子侧的内核级事件**,
|
||||||
|
但在**子的**阶梯上 L4 归父独占,所以它们不能作为"子自己的 L4";应当**上报给父**,
|
||||||
|
在**父的阶梯上以 L4(带子标识)**出现 —— 与 §9 的 contextfull 同一条通路。
|
||||||
|
(即:父侧 L4 = 子侧内核级事件的接收位;子侧 L4 = 父控制语的发射位。)
|
||||||
|
|
||||||
|
### 6.2 为什么 contextfull 是 L4 而不是 L3
|
||||||
|
|
||||||
|
它是**必须由父立刻决策**的场合(三个处置都与子的存续有关),且决策要读**一整个状态面** ⇒
|
||||||
|
只能"**推信号 + 拉状态**",不能把状态塞进中断消息里。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 父对子的控制面(6 个动作)[已定:原语在内核,决策在父的模型]
|
||||||
|
|
||||||
|
| 动作 | 语义 | 子是否继续存在 | 走哪条路 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **创建** | 划入输入通道 + 授权输出通道 + 授权插件/工具(默认完整)+ 在固定提示词之上注入任务提示词 | — | 内核原语 |
|
||||||
|
| **发送消息** | 经输出通道寻址到子的 inputch:**取消当前状态 + 插入新消息** | 是 | 对子 = **L4 中断** |
|
||||||
|
| **查看** | **pull** 子的状态面(处理表 + temp 产出),**不打断**子 | 是 | 纯查询,**不走中断** |
|
||||||
|
| **压缩** | **保留语义**:压上下文 + **清理处理表** | **是**(同一驻留子) | 见 §9 |
|
||||||
|
| **回收** | **取消语义**:父看状态面 → 选择哪些 **promote 进 main** → **取消**该驻留子 | 否 | 见 §9 |
|
||||||
|
| **销毁** | **立刻销毁并从登记表移除**(不收割) | 否 | 立即 |
|
||||||
|
|
||||||
|
- **创建/销毁/回收/查看/发送**是**父可调用的原语(工具)**;**决策**(压还是收、收哪些)
|
||||||
|
在父的模型手里 —— 内核不替父决定。
|
||||||
|
- **默认完整授权**[已定]:子默认拿到全部插件与工具(含输出门);
|
||||||
|
父可在创建时**收窄**(收窄工具子集、收窄可用输出通道集合)。
|
||||||
|
⚠️ 默认含输出门意味着**子可以直接对用户通道发消息**;若要默认收窄,改一处默认即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. inputch 处理表
|
||||||
|
|
||||||
|
### 8.1 归属与方向
|
||||||
|
|
||||||
|
- **子是持有者**;父**主动查看(pull)**,**不是**推给父。
|
||||||
|
- **内容**:按 inputch 记录**每一轮**子对该 inputch 的处理信息。
|
||||||
|
- **存在意义**:长期驻留子的**进度可见性** —— 父不必打断它就能知道它做到哪。
|
||||||
|
|
||||||
|
### 8.2 写入规则
|
||||||
|
|
||||||
|
- 子**主动写入**时,系统**不**自动写;
|
||||||
|
- 子**未主动写入**时,系统**自动**把该轮 inputch 对应的信息写进去;
|
||||||
|
- ⇒ **每一轮必有记录**,父不会看到空洞。
|
||||||
|
|
||||||
|
[默认]"主动写入"的动作形态 = 子调用一个 `inputch_note` 类**工具**;
|
||||||
|
自动写入在轮次结束时由内核兜底。
|
||||||
|
|
||||||
|
### 8.3 生命周期 = 上下文窗口
|
||||||
|
|
||||||
|
处理表记的是"**当前这段上下文窗口**里每轮 inputch 做了什么"。
|
||||||
|
因此:
|
||||||
|
|
||||||
|
- **压缩必须清表**(窗口被压成摘要后,逐轮记录被摘要取代;留着会让父看到与当前窗口
|
||||||
|
不对应的陈旧状态);
|
||||||
|
- **回收不必清表**(表就是父刚读过的收割材料,子都没了,表自然作废);
|
||||||
|
- ⇒ 处理表天然有**大小上界**(窗口多大、表最长多长),不需要额外容量策略。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. contextfull 的处置
|
||||||
|
|
||||||
|
```
|
||||||
|
子的上下文窗口满
|
||||||
|
└─ 产生 contextfull → **L4 中断**通知父(中断信息里标明是哪个子)—— 只推信号
|
||||||
|
└─ 父【查看】子的状态面(处理表 + temp 产出)
|
||||||
|
├─ 【压缩】压成摘要 → 清处理表 → 子续用 (保留语义)
|
||||||
|
├─ 【回收】选择 temp 中哪些 promote 进 main (取消语义)
|
||||||
|
│ → 丢弃 temp → 取消该驻留子
|
||||||
|
└─ 【销毁】立刻销毁并移除 (不收割)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 动作 | 语义 | 子上下文/成果 | 子 agent | 处理表 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **压缩** | **保留** | 压成摘要 | **继续存在** | **必须清理** |
|
||||||
|
| **回收** | **取消** | 选中的 promote 进 main | **取消** | 不必清 |
|
||||||
|
| **销毁** | 立刻销毁并移除 | 不收割 | 立刻销毁 + 出登记表 | 无关 |
|
||||||
|
|
||||||
|
[默认]压缩由**子的轻量内核自己执行**(它拥有自己的上下文与 LLM)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 登记表与生命周期硬约束
|
||||||
|
|
||||||
|
- 父持 **agent 登记表**,记录全部子:`id / 状态 / 划入的输入通道 / 授权的输出通道 /
|
||||||
|
授权的插件与工具 / 状态面句柄`。
|
||||||
|
- 它是**查看 · 发送 · 压缩 · 回收 · 销毁**的寻址依据。
|
||||||
|
- **硬约束(必须写成测试)**:
|
||||||
|
1. 父 `Stop()` ⇒ 销毁全部子(取消运行中的任务、停轻量内核、释放其通道),
|
||||||
|
**登记表清空、不留孤儿**;
|
||||||
|
2. **子不得比父活得久**(无孤儿 goroutine / 无悬空通道 / 无残留 temp)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 并发与不变量
|
||||||
|
|
||||||
|
沿用输入调度器的并发模型,并按多 agent 扩展:
|
||||||
|
|
||||||
|
- **每个 agent 一个调度器 goroutine**(根 agent 与每个驻留子各一个),
|
||||||
|
它**独占**自己的队列 / running / 中断栈 / 帧。
|
||||||
|
- 跨 agent 投递只经**通道**(值传递),**不共享帧**;父**永远不能**直接改子的帧。
|
||||||
|
- **子不得比父活得久**(§10)。
|
||||||
|
- 父的"查看"是**只读快照**,不阻塞子、不参与子的调度决策。
|
||||||
|
- **L4 独占**:子的调度器只接受来自父的 L4(§6.1)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 与现有实现的接合点(差距清单)
|
||||||
|
|
||||||
|
| 设计项 | 现状 | 要做 |
|
||||||
|
|---|---|---|
|
||||||
|
| 两类别 + 四级中断 + 抢占/挂起/恢复/中断栈 | ✅ 已落地(见 input-scheduler-design.md) | 复用 |
|
||||||
|
| inputch 一等化(归属/容量/授权) | ❌ `inputCh` 是全局单 channel;`ChannelDef` 只是策略表 | **新增通道注册与分配层** |
|
||||||
|
| 输出通道可寻址到 agent | ❌ 只有字符串标签;`output_list_channels` 列全部 | 通道解析表 + 授权过滤 |
|
||||||
|
| 子的 L4 = 父 | ⚠️ `isKernelLevelSource` 只认内置插件 | 泛化为"该 agent 的上级"(分层) |
|
||||||
|
| 轻量内核(记忆作用域化) | ❌ 记忆层绑定单一 main 空间 | 记忆子系统加 **space 维度**;`AgentConfig` 加**作用域参数** |
|
||||||
|
| 记忆写路径重定向到 temp | ❌ 写路径无空间概念 | 所有写入口带 space;子的一切写 → temp |
|
||||||
|
| 驻留子生命周期 + 登记表 | ❌ 只有一次性 `runChildTask` | 驻留子 + 父的登记表 + 退出清理 |
|
||||||
|
| 跨 agent 投递(子→父 L3 / 父→子 L4) | ❌ 无 | 投递原语(复用注入层 + 通道寻址) |
|
||||||
|
| inputch 处理表 | ❌ 无 | 新数据结构 + 主动写入工具 + 自动写兜底 |
|
||||||
|
| contextfull 检测 | ❌ **完全没有** | 检测 + L4 通知(带子标识)+ 三处置 |
|
||||||
|
| 内核不持有"当前通道" | ❌ `currentOutputChannel` + 提示词预设 | 删字段、删预设 |
|
||||||
|
| 工作式轻量子 | ✅ | **不动** |
|
||||||
|
|
||||||
|
### 16.0 N2c 施工方案(轻量内核接线)[已定方案:**窄接口 + nil 即禁用**]
|
||||||
|
|
||||||
|
**先按"谁在调"把 `a.memory` 的 42 处使用分类**(`grep` 实测,非估计):
|
||||||
|
|
||||||
|
| 分组 | 位置 | 方法 | 谁用 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **A 记忆整理流水线** | `distill.go`(10):`archiveLoop` / `reviewLoop` / `mergeLoop` / `detectEntityMerge` / `reviewRelations` / `archiveColdDocs` | `Recall`, `ClearSentenceID`, `CleanupOrphanedSentences` | **root-only**(后台定时器) |
|
||||||
|
| **B 记忆块 + 媒体桥** | `graphmedia.go`(18)、`medialoop.go`(4) | `PutMemoryBlocks`, `AddMemoryBlockEdge`, `BlocksForNode`, `PutDocumentNode`, `MemoryBlocks`, `MigrateLegacyMediaEntities`, `mediaContextFor*` | **root-only** |
|
||||||
|
| **C 记忆整理工具** | `toolcall.go::executeMemoryTool`(8) | `Introspect`, `MergeEntities`, `DeleteEntity`, `Purge`, `Commit` | **root-only**(`memory_merge`/`memory_delete_entity`/`memory_block_merge`/`memory_purge`/`memory_edit`/`memory_stats`) |
|
||||||
|
| **D 共同面** | `graphmedia.go:114`(自动写入)、`toolcall.go:130`(`memory_recall`) | **只有 `Recall` + `Commit`** | 根与子都要 |
|
||||||
|
| **E 判空/状态** | 22 处 `if a.memory != nil` + `GetKernelStatus` + `buildToolDefs` | — | 既有关卡 |
|
||||||
|
|
||||||
|
⇒ **子 agent 需要的记忆面只有 `Recall` + `Commit`**;其余全是"整理记忆 / 记忆整理流水线"
|
||||||
|
(用户指出的关键点),**子根本不该有那些代码路径**。
|
||||||
|
|
||||||
|
#### 设计:窄接口 + nil 即禁用(不写"18 个方法返回错误"的受限包装)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// core 内部:共同面(根与子都要)
|
||||||
|
type GraphMemory interface {
|
||||||
|
Recall(keywords, seedEntities []string, depth int, sessionFilter string) (*memory.RecallResult, error)
|
||||||
|
Commit(triples []memory.Triple, sessionID string, turnID int) (int, int, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| | `a.graph`(共同面) | `a.memory`(整理面:块/媒体/流水线/整理工具) |
|
||||||
|
|---|---|---|
|
||||||
|
| **根 agent** | 同一个 `*GraphDB` | `*GraphDB` |
|
||||||
|
| **驻留子** | `*LightMemory` | **`nil`** |
|
||||||
|
|
||||||
|
- 子把 `a.memory` 设为 `nil` ⇒ **既有的 22 处 nil 关卡自动禁掉全部 root-only 路径**
|
||||||
|
(`executeMemoryTool` 开头已经是 `if a.memory == nil { return "图记忆系统不可用" }`)。
|
||||||
|
- 唯一要拆的是**自动写入路径** `commitTriplesWithMedia`:
|
||||||
|
图部分 → `a.graph.Commit`;块/媒体部分 → 由 `a.memory != nil` 守卫。
|
||||||
|
- 工具表:`memory_recall`(读)对子开放;整理类
|
||||||
|
(`memory_merge`/`memory_delete_entity`/`memory_block_merge`/`memory_purge`/`memory_edit`/`memory_stats`)
|
||||||
|
**不进子的工具表**(而不是让它们进去再报"不可用")。
|
||||||
|
- 轻量 profile 另外不接线的装配:doc 记忆 / context 动态上下文(`pruneOnInput` 等)/
|
||||||
|
蒸馏 / 归档 / 关系复审 / 实体合并定时器 / consolidation / 人格门禁。
|
||||||
|
|
||||||
|
### 16.0.0 传统上下文的实现口径(子 vs 父)
|
||||||
|
|
||||||
|
"传统上下文"不是一句口号,它对应三处**代码闸门**(都按 `isLightKernel()` 判):
|
||||||
|
|
||||||
|
| 能力 | 父(完整内核) | 子(轻量内核) | 闸门位置 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 时间线拼装预算 | `budget.ContextTokens`(动态上下文算出的份额,≈窗口 32%~53%) | **整个窗口** `budget.MaxContext` | `contextTokenBudget()`(`stepPrepare` 与 `rebaseFramePrefix` 两处) |
|
||||||
|
| 按相关度裁剪 + 向 doc 记忆归档 | 通道/注入点声明 `context_policy=prune` 时执行 | **不执行** | `pruneOnInput()` 前置返回 |
|
||||||
|
| doc 记忆 / 记忆整理流水线 | 有 | 无(`a.memory == nil` ⇒ 既有 22 处关卡自动关闭) | `memoryface.go` / `tooldefs.go` |
|
||||||
|
|
||||||
|
**"不裁"的准确含义**:不做**策略性**裁剪(不按相关度挑、不归档),只受"模型能收多少"这个
|
||||||
|
**硬上限**约束;而且在撞到硬上限之前,contextfull(90% 窗口)已按 L4 上报父 agent ——
|
||||||
|
**丢事件的决定权在父,不在内核**(父可压缩/回收/销毁)。
|
||||||
|
|
||||||
|
**顺带修掉的既有 bug**:`formatMergedTimeline` 逐事件估算原用 `len()`(**字节**)再 ×2,
|
||||||
|
而 `EstimateTokens` 是 rune×2 ⇒ 中文事件被高估 3 倍,窗口还有余量也提前 break、
|
||||||
|
把更早事件整段丢掉(实测 2384 字中文事件被估成 14398 token > 8192)。已改为统一的
|
||||||
|
`EstimateTokens`。这条 bug 对父同样有效(中文长会话会被过早裁剪)。
|
||||||
|
|
||||||
|
### 16.0.1 工具面(已实现)
|
||||||
|
|
||||||
|
| 工具 | 谁用 | 作用 |
|
||||||
|
|---|---|---|
|
||||||
|
| `resident_agents` | 父 | **单工具多动作**:`list` / `create`(划入 inputch + 授权输出通道 + 注入任务提示词)/ `send`(对子 = L4)/ `inspect`(pull 处理表,不打断)/ `compress`(保留)/ `reclaim`(取消 + 合入)/ `destroy` |
|
||||||
|
| `notify_parent` | 子 | 主动汇报(父侧 = **L3 中断**) |
|
||||||
|
| `inputch_note` | 子 | 主动写本轮 inputch 处理信息(写了就不自动写) |
|
||||||
|
|
||||||
|
> 声明是条件式的:父(`parentID == ""`)才有 `resident_agents`;子才有 `notify_parent` / `inputch_note`。
|
||||||
|
|
||||||
|
### 16.1 N2 的记忆面清单(现状)
|
||||||
|
|
||||||
|
`internal/memory/` 下需要加 space 维度的面:
|
||||||
|
|
||||||
|
| 面 | 载体 | 表 | 子 agent 可用? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **图记忆** | `memory.GraphDB` | `entities` / `sentences` / `relations` | ✅ **作用域化**(读 temp∪main,写 temp) |
|
||||||
|
| **图记忆的向量检索** | `memory.Indexer` | (索引侧,与图记忆同步) | ✅ 同图记忆(需按 space 过滤) |
|
||||||
|
| 文档记忆(doc 记忆) | `document.Store` | `documents` | ❌ 父专属 |
|
||||||
|
| context 动态上下文 | 内核上下文装配/裁剪(`pruneOnInput` 等) | — | ❌ 父专属;子用传统上下文 |
|
||||||
|
| 知识库 | `knowledge.Store` | 各自表 | ❌ |
|
||||||
|
| 文本记忆 | `text.Memory` | 各自表 | ❌ |
|
||||||
|
| 媒体 | `media.Store` | 落盘 + 索引 | ❌ |
|
||||||
|
| 社交 | `social` | 各自表 | ❌ |
|
||||||
|
|
||||||
|
⇒ N2a 做**图记忆 + 其向量检索**这一条纵切(子唯一可用的记忆面,
|
||||||
|
也是"子写 temp / 父 promote 进 main"的主战场);其余面在 v1 **不加 space 维度**
|
||||||
|
(子根本够不到,加了是白工)——若将来子扩展记忆面再逐面补。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 非目标(本文明确不做)
|
||||||
|
|
||||||
|
1. 跨进程 / 跨主机的驻留子(v1 只在同进程内)。
|
||||||
|
2. 子的**子**(驻留子再创建驻留子)——先不做,保留扩展位。
|
||||||
|
3. main 空间的**多写者**(父是唯一写者,不做并发合并)。
|
||||||
|
4. temp 空间的持久化(跟子同生共死,不落盘)。
|
||||||
|
5. 工作式轻量子的任何行为变更。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 测试点、方式与预期
|
||||||
|
|
||||||
|
| 编号 | 测试点 | 方式 | 预期 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| S1 | 创建:划入输入通道 + 授权输出通道 | 创建子,向划入的 inputch 投输入 | 子处理它;未划入的 inputch 投不进(或报错) |
|
||||||
|
| S2 | 授权收窄 | 创建时只授权部分工具/插件 | 子的工具表恰为该子集;`output_list_channels` 只列授权的 |
|
||||||
|
| S3 | 默认完整授权 | 不传授权参数创建 | 子拿到全部插件/工具 |
|
||||||
|
| S4 | 子→父 L3 | 子在工作中主动发消息 | 父侧收到 **L3 中断**且带子标识;父可被打断(非临界区时) |
|
||||||
|
| S5 | contextfull → L4 | 灌满子的上下文 | 父侧收到 **L4 中断**、带子标识,且**只推信号** |
|
||||||
|
| S6 | 父→子 L4 | 父"发送消息"到指定子 | 子在收到时被打断(L4 > 子内部一切),按 §15 的默认挂起/恢复 |
|
||||||
|
| S7 | 查看(pull 不打断) | 子在跑长任务时父"查看" | 返回状态面快照;**子的 step/帧不变**、未被抢占 |
|
||||||
|
| S8 | 处理表:自动写兜底 | 子一轮不主动写 | 该轮仍有记录(系统自动写) |
|
||||||
|
| S9 | 处理表:主动写优先 | 子主动写 `inputch_note` | 该轮只有主动写的内容,无自动写 |
|
||||||
|
| S10 | 压缩(保留语义) | 父选压缩 | 上下文变短;**处理表被清空**;**子继续存在**且能继续干活 |
|
||||||
|
| S11 | 回收(取消语义) | 父选回收并挑若干条 promote | 选中内容进 **main**、其余丢弃;**temp 被丢弃**;**子被取消** |
|
||||||
|
| S12 | 销毁(立刻) | 父销毁(含子正在跑工具/LLM 时) | 子立刻消失、出登记表、其 temp 丢弃、通道释放 |
|
||||||
|
| S13 | 子不得写 main | 子调记忆写工具 | 落在 **temp**;main 无新增 |
|
||||||
|
| S14 | 子查询范围 = temp ∪ main | 子查只在 main 里的内容 / 只在 temp 里的内容 | 两者都能查到 |
|
||||||
|
| S15 | 子之间 temp 隔离 | 两个子各写 temp,互相查 | 查不到对方的 temp |
|
||||||
|
| S16 | 父退出清理 | 父 `Stop()`(多个子、有子在工作中) | 全部子被销毁;登记表空;**无孤儿 goroutine / 无悬空通道 / 无残留 temp** |
|
||||||
|
| S17 | L4 独占 | 子内部(子自己的输入/工具/定时器)试图产生 L4 | 被夹到 **≤L3**;只有父的消息是 L4 |
|
||||||
|
| S21 | 一个插件多个 inputch 可分别路由 | 同一插件注册 2 个 inputch,分别划给父与子后各投一条输入 | 各自只到被划给的 agent,互不串台 |
|
||||||
|
| S22 | inputch 总览(单工具多视图) | 一个插件注册 2 个 inputch、另一插件 1 个;把其中若干划给本 agent | `view=all` 列出全部(带归属插件);`mine`/`unassigned` 各自正确;`by_agent` 给出划分总览;`detail` 给出单条全字段;未知 view 报错并列出可用值 |
|
||||||
|
| S23 | 插件重载不抹划分 | 先划分 inputch,再重复登记(模拟插件重载) | Owner/Capacity 保留,仅策略被更新 |
|
||||||
|
| S19 | 父消息必能打断子 | 子在长任务中(LLM 流式段)时父发送消息 | 子按 L4 被打断;若子在不可抢占临界区,则在安全点生效 |
|
||||||
|
| S20 | 子的内核级事件上报 | 子 panic / 子 contextfull | 在**父的阶梯上以 L4(带子标识)**出现;子侧不自己产生 L4 |
|
||||||
|
| S18 | 内核不持有"当前通道" | 抢占/中断后被打断任务恢复并发响应 | 提示词与事件标签都**只来自输入事件**(不再有被覆盖的字段) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 待确认决策(含默认取值)
|
||||||
|
|
||||||
|
| 编号 | 问题 | 取值 |
|
||||||
|
|---|---|---|
|
||||||
|
| **R1** | 驻留子的内核形态 | **[已定]独立轻量内核**(自己的调度器/中断栈/上下文/记忆作用域) |
|
||||||
|
| **R2** | 插件与工具 | **[已定]父授权,默认完整授权**(可收窄) |
|
||||||
|
| **R3** | 记忆模型 | **[已定]两级空间**:读 temp∪main,写 temp(**范围 = 图记忆**) |
|
||||||
|
| **R3b** | 子的记忆面 | **[已定]传统上下文 + 图记忆**;doc 记忆与 context 动态上下文是**父专属**(内核独立设计的记忆能力) |
|
||||||
|
| **R13** | inputch 总览的形态 | **[已定]单工具多视图**(`input_channels` + `view`) |
|
||||||
|
| **R11** | 父消息的级别 | **[已定]对子 = L4**(子的阶梯上唯一 L4 来源;子内部一律 ≤L3) |
|
||||||
|
| **R12** | 子的内核级事件(panic / contextfull)上报级别 | **[默认/推论]在父的阶梯上以 L4(带子标识)上报** |
|
||||||
|
| **R4** | 父→子消息落地 | **[默认]挂起/恢复**(现场不丢);一处开关可改"直接丢弃" |
|
||||||
|
| **R5** | "主动写入处理表"的形态 | **[默认]子调用 `inputch_note` 类工具**;未调用则轮末自动写 |
|
||||||
|
| **R6** | 输入通道"划入"的语义与容量 | **[默认]读写授权(不转移所有权)+ 创建时给定容量**;**划入单位 = inputch**(不是插件、不是通道组) |
|
||||||
|
| **R7** | 压缩由谁执行 | **[默认]子的轻量内核自己压**(它有自己的上下文与 LLM),压缩后清表 |
|
||||||
|
| **R8** | 压缩前父是否"查看后决定" | **[默认]纯机械压缩**(父只在选"压缩 vs 回收"时决策) |
|
||||||
|
| **R9** | 回收时处理表 | **[默认]不必清**(子都没了);若日后要留作审计需单独策略 |
|
||||||
|
| **R10** | 默认授权是否含输出门 | **[默认]含**("默认完整授权"的字面含义);若嫌宽,改默认即可 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 实现里程碑(每步 = 一个可独立验收的提交)
|
||||||
|
|
||||||
|
| 里程碑 | 内容 | 验收 |
|
||||||
|
|---|---|---|
|
||||||
|
| **N0** | **无状态化**:删 `Agent.currentOutputChannel`、删提示词里的通道预设 | S18;既有全部测试通过(这是纯收敛,不含新能力) |
|
||||||
|
| **N1a** | **通道登记层**:inputch 一等化(归属插件 / 归属 agent / 容量 / 共享登记表)+ **单工具多视图总览** | S21–S23 |
|
||||||
|
| **N1b** | 输出通道授权过滤 + 目标解析(outputch → 目标 agent 的 inputch) | S1–S3 |
|
||||||
|
| **N2a** | ~~作用域对象 + 图记忆 space 维度~~ **已完成(改为独立存储实例)**:`OpenGraphDBReadOnly`(query_only 受限句柄)+ `LightMemory`(temp 可写 / 主库只读 / 并集查询 + 应用层合并) | S13–S15 ✅ |
|
||||||
|
| **N2b** | 图记忆的向量检索在并集下的排序/去重(当前按实体名/三元组合并,检索排序沿用单库语义) | 待做(非阻塞) |
|
||||||
|
| — | 其余记忆面(doc 记忆 / 动态上下文 / 知识库 / 文本 / 媒体 / 社交):**v1 不加 space**(子不可达) | 由 S13/S14 隐含 |
|
||||||
|
| **N2c** | ~~Agent 级 profile~~ **已完成**:`GraphMemory` 窄接口(Recall/Commit)+ `a.graph` 共同面;子 `a.memory = nil` ⇒ 22 处既有关卡自动禁用整理面 | S13–S15 ✅ |
|
||||||
|
| **N2d** | ~~晋升与丢弃~~ **数据面已完成**:`GraphDB.ExportTriples` + 复用 `Commit` 合入(父选哪几条);`LightMemory.Close()` 丢弃 temp | S11 数据面 ✅ |
|
||||||
|
| **N3** | ~~驻留子生命周期~~ **已完成**:`SpawnResident` / `DestroyResident` / `Residents()`(登记表)/ `Stop()` 内 `StopResidents()`(父退出不留孤儿)/ 归还划入的 inputch / 丢弃 temp 目录 | S12、S16 ✅ |
|
||||||
|
| **N4** | ~~跨 agent 投递~~ **已完成**:子→父 `notify_parent`(L3,投父的 `child/<id>` inputch);父→子 `SendToResident`(L4,`KernelSource` 分层使父在子的阶梯上是唯一 L4 来源);子的 contextfull 经 `raiseKernelInterrupt` 以 L4 上报父 | S4、S6、S17、S19、S20 ✅ |
|
||||||
|
| **N5** | ~~inputch 处理表~~ **已完成**:`inputch_note`(主动写优先)+ `autoRecordInputch`(轮末兜底)+ `CompressResident` 清表 + 父 `ResidentTable(id)` pull 查看 | S8、S9 ✅ |
|
||||||
|
| **N6** | ~~contextfull~~ **已完成**:判据 = **未裁剪的积累上下文**超过窗口 90%(不能用拼好的 `f.Msgs`——它被 token 预算封在 ~80% 窗口内,是永不成立的判据);通知 = 父侧 L4(`child/<id>`);三处置 = `CompressResident`(保留:`TrimKeepRecent` + 清表)/ `ReclaimResident`(取消:`ExportTriples` 选出后 `Commit` 进 main)/ `DestroyResident` | S5、S10、S11 ✅ |
|
||||||
|
| **N7** | ~~e2e + 压力~~ **已完成**:`resident_test.go` 五项(生命周期/双向投递/处理表/contextfull 三处置/8 子×12 轮压力 + 双向汇报),`-race -count=3` 干净 | S1–S20 覆盖 ✅ |
|
||||||
|
|
||||||
|
每步收尾命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GOCACHE=/tmp/gocache GOPATH=/tmp/gopath TMPDIR=/var/tmp/gotmp
|
||||||
|
gofmt -l internal/agent internal/plugin internal/sdk # 本步新增文件必须为空
|
||||||
|
go build ./... && go vet ./...
|
||||||
|
go test -count=1 ./... && go test -race -count=1 ./internal/agent/... ./internal/plugin/...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 与发布纪律的关系
|
||||||
|
|
||||||
|
- 本设计在 `feature/input-semantics` 之后的特性分支上开发,完成后合回 `main`。
|
||||||
|
- 若需要动公开 SDK(例如新增 `agent_*` 控制面原语、通道授权字段),按"**只增不减、签名不改**"
|
||||||
|
追加,并同步 `docs/zh/plugin-interface-matrix.md` 与 SDK 仓版本。
|
||||||
@ -26,19 +26,47 @@ import (
|
|||||||
// ContextEvent 和 RelevanceContext 定义在 context.go
|
// ContextEvent 和 RelevanceContext 定义在 context.go
|
||||||
|
|
||||||
// Agent — 单 agent,不区分会话/实例
|
// Agent — 单 agent,不区分会话/实例
|
||||||
|
//
|
||||||
|
// 并发现状(M3a 起):所有任务状态只由 **schedulerLoop goroutine** 独占读写,
|
||||||
|
// 因此不再有保护整轮执行的互斥量——挂起不能持锁(见 docs/zh/input-scheduler-design.md §8.1 I3)。
|
||||||
|
// 仍需跨 goroutine 保护的是:childMu/llmMu/lastInputMu/noMergeMu 与各子系统自己的锁;
|
||||||
|
// interceptLoop 只允许触碰 preemptionRequest 与 cancelLLM(经 llmMu)。
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
mu sync.Mutex
|
|
||||||
id types.AgentID
|
id types.AgentID
|
||||||
provider agentAPI.Provider
|
provider agentAPI.Provider
|
||||||
providerManager *agentAPI.ProviderManager
|
providerManager *agentAPI.ProviderManager
|
||||||
io *agentIO.IOManager
|
io *agentIO.IOManager
|
||||||
memory *memory.GraphDB
|
memory *memory.GraphDB
|
||||||
indexer *memory.Indexer
|
// graph 是本 agent 的**图记忆共同面**(根 = 同一个 GraphDB;驻留子 = LightMemory)。
|
||||||
tracker *tracker.Tracker
|
// 整理面仍走 memory 字段(子为 nil ⇒ 既有的 nil 关卡自动禁用整理面)。
|
||||||
context *RelevanceContext
|
graph GraphMemory
|
||||||
systemPrompt string
|
|
||||||
ctx context.Context
|
// kernelSource/parentID/taskPrompt/dataDir:驻留子相关的层级信息(见 AgentConfig)。
|
||||||
cancel context.CancelFunc
|
kernelSource string
|
||||||
|
parentID string
|
||||||
|
taskPrompt string
|
||||||
|
dataDir string
|
||||||
|
|
||||||
|
// 驻留子(父侧):登记表 + 子侧钩子。
|
||||||
|
residentMu sync.Mutex
|
||||||
|
residents map[string]*residentChild
|
||||||
|
|
||||||
|
// 子侧:向父发消息(L3)与 contextfull 上报(父侧内核级事件)的钩子。
|
||||||
|
notifyParent func(text string)
|
||||||
|
onContextFull func()
|
||||||
|
ctxFullSignaled bool
|
||||||
|
|
||||||
|
// 子侧:inputch 处理表(子持有,父 pull)。
|
||||||
|
tableMu sync.Mutex
|
||||||
|
inputchTable []InputchRecord
|
||||||
|
inputchPending *InputchRecord
|
||||||
|
currentInputch string
|
||||||
|
indexer *memory.Indexer
|
||||||
|
tracker *tracker.Tracker
|
||||||
|
context *RelevanceContext
|
||||||
|
systemPrompt string
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
|
||||||
// 文档记忆(第二层)
|
// 文档记忆(第二层)
|
||||||
docStore *document.Store
|
docStore *document.Store
|
||||||
@ -64,6 +92,9 @@ type Agent struct {
|
|||||||
// 为 nil 时门禁与工具都静默关闭(例如单测里不接配置的场景)。
|
// 为 nil 时门禁与工具都静默关闭(例如单测里不接配置的场景)。
|
||||||
personaStore PersonaStore
|
personaStore PersonaStore
|
||||||
|
|
||||||
|
// 被授权的输出通道集合(空 = 完整授权,见 AgentConfig.AllowedOutputs)。
|
||||||
|
allowedOutputs []string
|
||||||
|
|
||||||
// 插件注册表(用于 plgreload)
|
// 插件注册表(用于 plgreload)
|
||||||
pluginReg *plugin.Registry
|
pluginReg *plugin.Registry
|
||||||
pluginDir string
|
pluginDir string
|
||||||
@ -80,7 +111,6 @@ type Agent struct {
|
|||||||
maxContextSize int
|
maxContextSize int
|
||||||
|
|
||||||
// 当前请求的输出通道(mutex 保护,process() 内独占)
|
// 当前请求的输出通道(mutex 保护,process() 内独占)
|
||||||
currentOutputChannel string
|
|
||||||
|
|
||||||
// 阶段管道:插件消息流编辑
|
// 阶段管道:插件消息流编辑
|
||||||
stageHost *StageHost
|
stageHost *StageHost
|
||||||
@ -106,8 +136,12 @@ type Agent struct {
|
|||||||
// childSeq 给完成的任务排个序,用于有界淘汰。
|
// childSeq 给完成的任务排个序,用于有界淘汰。
|
||||||
childSeq int64
|
childSeq int64
|
||||||
|
|
||||||
// 高优先级打断通道:interceptLoop 注入,process() 在工具循环轮次间非阻塞读取
|
// 输入调度器:就绪队列、任务抽象与快照(见 scheduler.go)。
|
||||||
interceptCh chan *agentIO.InputEvent
|
// M2 起取代 eventLoop 的隐式 channel 排队。
|
||||||
|
sched *scheduler
|
||||||
|
|
||||||
|
// 工具轮次硬上限(0 = 不限);见 AgentConfig.MaxToolTurns。
|
||||||
|
maxToolTurns int
|
||||||
|
|
||||||
// 进行中的 LLM 请求取消函数,interceptLoop 可调用以在请求中打断
|
// 进行中的 LLM 请求取消函数,interceptLoop 可调用以在请求中打断
|
||||||
cancelLLM context.CancelFunc
|
cancelLLM context.CancelFunc
|
||||||
@ -128,7 +162,7 @@ type Agent struct {
|
|||||||
//
|
//
|
||||||
// 需要缓存而不是当场挂到事件上:媒体在 process() 执行期间被捕获,
|
// 需要缓存而不是当场挂到事件上:媒体在 process() 执行期间被捕获,
|
||||||
// 而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
|
// 而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
|
||||||
// 与 pendingMedia 同受 a.mu 保护。
|
// 由 schedulerLoop goroutine 独占读写。
|
||||||
pendingMediaDigests []string
|
pendingMediaDigests []string
|
||||||
|
|
||||||
// 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息)
|
// 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息)
|
||||||
@ -182,8 +216,13 @@ type AgentConfig struct {
|
|||||||
ProviderManager *agentAPI.ProviderManager
|
ProviderManager *agentAPI.ProviderManager
|
||||||
IO *agentIO.IOManager
|
IO *agentIO.IOManager
|
||||||
Memory *memory.GraphDB
|
Memory *memory.GraphDB
|
||||||
Indexer *memory.Indexer
|
// LightMemory 是**轻量内核**的图记忆装配(驻留子用;读 temp∪main,只写 temp)。
|
||||||
Tracker *tracker.Tracker
|
//
|
||||||
|
// 给了它就意味着这是轻量内核:`Memory` 必须为 nil,
|
||||||
|
// 于是记忆整理面(块/媒体/流水线/整理工具)全部不可达(见 memoryface.go)。
|
||||||
|
LightMemory *memory.LightMemory
|
||||||
|
Indexer *memory.Indexer
|
||||||
|
Tracker *tracker.Tracker
|
||||||
|
|
||||||
DocStore *document.Store
|
DocStore *document.Store
|
||||||
Knowledge *knowledge.Store
|
Knowledge *knowledge.Store
|
||||||
@ -193,12 +232,29 @@ type AgentConfig struct {
|
|||||||
MultimodalSpace vector.MultimodalEmbedder
|
MultimodalSpace vector.MultimodalEmbedder
|
||||||
// EmbeddingProvider / EmbeddingError 是向量空间的配置身份与打开失败原因,
|
// EmbeddingProvider / EmbeddingError 是向量空间的配置身份与打开失败原因,
|
||||||
// 供 healthcheck_kernel 状态报告区分「未配置 / 打开失败 / 已启用」。
|
// 供 healthcheck_kernel 状态报告区分「未配置 / 打开失败 / 已启用」。
|
||||||
EmbeddingProvider string
|
EmbeddingProvider string
|
||||||
EmbeddingError string
|
EmbeddingError string
|
||||||
FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认
|
FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认
|
||||||
Personality *agentPkg.Personality
|
Personality *agentPkg.Personality
|
||||||
PersonaStore PersonaStore // 人格设定的读写面(首启门禁 + persona_set 工具)
|
PersonaStore PersonaStore // 人格设定的读写面(首启门禁 + persona_set 工具)
|
||||||
PluginReg *plugin.Registry
|
PluginReg *plugin.Registry
|
||||||
|
// KernelSource 是本 agent 的"上级"(驻留子的父)。
|
||||||
|
//
|
||||||
|
// 设计 §6.1:某个 agent 的 L4 只属于它的**内核** —— 根 agent 的内核是内核自身与
|
||||||
|
// 内核级插件;驻留子的内核是**父 agent**。因此子的 KernelSource = 父 ⇒ 只有父
|
||||||
|
// 能在子的阶梯上产生 L4(父的"发送消息")。
|
||||||
|
KernelSource string
|
||||||
|
// ParentID 是父 agent 的 id(空 = 根 agent)。子用它判断自己是不是驻留子。
|
||||||
|
ParentID string
|
||||||
|
// DataDir 是本 agent 的数据目录;创建驻留子时用它派生 temp 图记忆路径。
|
||||||
|
DataDir string
|
||||||
|
// TaskPrompt 是在固定提示词之上注入的**任务提示词**(驻留子创建时给定)。
|
||||||
|
TaskPrompt string
|
||||||
|
// AllowedOutputs 是本 agent **被授权的输出通道集合**(设计 §4.4 / R2)。
|
||||||
|
//
|
||||||
|
// nil 或空 = **完整授权**(默认);非空 = 白名单,只允许列出的输出通道。
|
||||||
|
// 父 agent 创建驻留子时用它收窄子的输出能力。
|
||||||
|
AllowedOutputs []string
|
||||||
PluginDir string
|
PluginDir string
|
||||||
DistillInterval time.Duration
|
DistillInterval time.Duration
|
||||||
ArchiveInterval time.Duration // 冷文档归档间隔(L2→L3),0 则使用 DistillInterval
|
ArchiveInterval time.Duration // 冷文档归档间隔(L2→L3),0 则使用 DistillInterval
|
||||||
@ -215,6 +271,10 @@ type AgentConfig struct {
|
|||||||
SkillIndexProvider SkillIndexProvider
|
SkillIndexProvider SkillIndexProvider
|
||||||
|
|
||||||
InputProcessing types.InputProcessingConfig // 非文本输入处理配置
|
InputProcessing types.InputProcessingConfig // 非文本输入处理配置
|
||||||
|
|
||||||
|
// MaxToolTurns 是单个任务允许的工具轮次上限(0 = 不限)。
|
||||||
|
// 设计文档 D6:主循环必须有硬上限,否则模型不停调用就永不完结。
|
||||||
|
MaxToolTurns int
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg AgentConfig) *Agent {
|
func New(cfg AgentConfig) *Agent {
|
||||||
@ -272,6 +332,11 @@ func New(cfg AgentConfig) *Agent {
|
|||||||
providerManager: cfg.ProviderManager,
|
providerManager: cfg.ProviderManager,
|
||||||
io: cfg.IO,
|
io: cfg.IO,
|
||||||
memory: cfg.Memory,
|
memory: cfg.Memory,
|
||||||
|
graph: graphMemoryOf(cfg),
|
||||||
|
kernelSource: cfg.KernelSource,
|
||||||
|
parentID: cfg.ParentID,
|
||||||
|
taskPrompt: cfg.TaskPrompt,
|
||||||
|
dataDir: cfg.DataDir,
|
||||||
indexer: cfg.Indexer,
|
indexer: cfg.Indexer,
|
||||||
tracker: cfg.Tracker,
|
tracker: cfg.Tracker,
|
||||||
context: rc,
|
context: rc,
|
||||||
@ -285,6 +350,7 @@ func New(cfg AgentConfig) *Agent {
|
|||||||
mediaStore: cfg.MediaStore,
|
mediaStore: cfg.MediaStore,
|
||||||
personality: cfg.Personality,
|
personality: cfg.Personality,
|
||||||
personaStore: cfg.PersonaStore,
|
personaStore: cfg.PersonaStore,
|
||||||
|
allowedOutputs: cfg.AllowedOutputs,
|
||||||
pluginReg: cfg.PluginReg,
|
pluginReg: cfg.PluginReg,
|
||||||
pluginDir: cfg.PluginDir,
|
pluginDir: cfg.PluginDir,
|
||||||
distillInterval: cfg.DistillInterval,
|
distillInterval: cfg.DistillInterval,
|
||||||
@ -297,7 +363,8 @@ func New(cfg AgentConfig) *Agent {
|
|||||||
eventBus: cfg.EventBus,
|
eventBus: cfg.EventBus,
|
||||||
selfInputCh: make(chan selfInputMsg, 64),
|
selfInputCh: make(chan selfInputMsg, 64),
|
||||||
childTasks: make(map[string]*childTaskState),
|
childTasks: make(map[string]*childTaskState),
|
||||||
interceptCh: make(chan *agentIO.InputEvent, 64),
|
sched: newScheduler(256),
|
||||||
|
maxToolTurns: cfg.MaxToolTurns,
|
||||||
pluginHealth: newPluginHealthTracker(),
|
pluginHealth: newPluginHealthTracker(),
|
||||||
thinkingEnabled: cfg.ThinkingEnabled,
|
thinkingEnabled: cfg.ThinkingEnabled,
|
||||||
inputCfg: cfg.InputProcessing,
|
inputCfg: cfg.InputProcessing,
|
||||||
@ -315,7 +382,7 @@ func New(cfg AgentConfig) *Agent {
|
|||||||
func (a *Agent) SetSkillIndexProvider(p SkillIndexProvider) { a.skillIndex = p }
|
func (a *Agent) SetSkillIndexProvider(p SkillIndexProvider) { a.skillIndex = p }
|
||||||
|
|
||||||
func (a *Agent) Start() {
|
func (a *Agent) Start() {
|
||||||
go a.eventLoop()
|
go a.schedulerLoop()
|
||||||
go a.interceptLoop()
|
go a.interceptLoop()
|
||||||
go a.distillLoop()
|
go a.distillLoop()
|
||||||
go a.archiveLoop()
|
go a.archiveLoop()
|
||||||
@ -327,11 +394,68 @@ func (a *Agent) Start() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) Stop() {
|
func (a *Agent) Stop() {
|
||||||
|
// 父退出**必须**销毁全部驻留子(设计 §10 硬约束:子不得比父活得久、不留孤儿)。
|
||||||
|
a.StopResidents()
|
||||||
a.cancel()
|
a.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// graphMemoryOf 决定本 agent 的图记忆共同面实现。
|
||||||
|
//
|
||||||
|
// - 轻量内核(给了 LightMemory):用 LightMemory,**整理面保持 nil**;
|
||||||
|
// - 完整内核:直接用主图库(*memory.GraphDB 天然满足 GraphMemory)。
|
||||||
|
func graphMemoryOf(cfg AgentConfig) GraphMemory {
|
||||||
|
if cfg.LightMemory != nil {
|
||||||
|
return cfg.LightMemory
|
||||||
|
}
|
||||||
|
if cfg.Memory == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cfg.Memory
|
||||||
|
}
|
||||||
|
|
||||||
|
// graphMem 返回本 agent 的图记忆**共同面**。
|
||||||
|
//
|
||||||
|
// `graph` 显式为 nil 时回落到 `memory` —— 这样"只设 memory 的构造"
|
||||||
|
// (大量既有测试直接用 Agent 字面量)照常工作,不需要同时维护两个字段。
|
||||||
|
// 轻量内核则显式设 graph=LightMemory 且 memory=nil:共同面走 LightMemory,
|
||||||
|
// 整理面因 memory==nil 而全部不可达。
|
||||||
|
func (a *Agent) graphMem() GraphMemory {
|
||||||
|
if a.graph != nil {
|
||||||
|
return a.graph
|
||||||
|
}
|
||||||
|
if a.memory == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return a.memory
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Agent) ID() types.AgentID { return a.id }
|
func (a *Agent) ID() types.AgentID { return a.id }
|
||||||
|
|
||||||
|
// IsOutputAllowed 报告某个输出通道是否被授权给本 agent。
|
||||||
|
//
|
||||||
|
// 默认(未配置白名单)= **完整授权**;这是"默认完整授权、父可收窄"的落点。
|
||||||
|
func (a *Agent) IsOutputAllowed(channel string) bool {
|
||||||
|
if len(a.allowedOutputs) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, c := range a.allowedOutputs {
|
||||||
|
if c == channel {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveOutputTarget 解析输出通道的投递目标(agent + inputch)。
|
||||||
|
//
|
||||||
|
// ok=false 表示该输出通道由传输层(device 通道,如 qq/webui)自行处理。
|
||||||
|
func (a *Agent) ResolveOutputTarget(channel string) (agentIO.OutputTarget, bool) {
|
||||||
|
if a.io == nil || a.io.ChannelRegistry() == nil {
|
||||||
|
return agentIO.OutputTarget{}, false
|
||||||
|
}
|
||||||
|
return a.io.ChannelRegistry().ResolveOutputTarget(channel)
|
||||||
|
}
|
||||||
|
|
||||||
// isDuplicateInput 判断是否为短窗口内的重复输入(防 webui/GUI 断线重连消息重放)。
|
// isDuplicateInput 判断是否为短窗口内的重复输入(防 webui/GUI 断线重连消息重放)。
|
||||||
// key=source+"|"+content;窗口内重复返回 true 并刷新时间戳(持续轰炸时保持拦截)。
|
// key=source+"|"+content;窗口内重复返回 true 并刷新时间戳(持续轰炸时保持拦截)。
|
||||||
const duplicateInputWindow = 10 * time.Second
|
const duplicateInputWindow = 10 * time.Second
|
||||||
|
|||||||
@ -451,6 +451,29 @@ func (c *RelevanceContext) Blocks() []memory.MemoryBlock {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TrimKeepRecent 只保留最近 n 条事件,丢弃更旧的(返回丢弃条数)。
|
||||||
|
//
|
||||||
|
// 这是**压缩上下文**(保留语义)的机械原语:不归档、不写任何记忆,直接丢弃旧事件。
|
||||||
|
// 用于轻量内核(驻留子):它没有 doc 记忆与记忆整理流水线,压缩只能是"保留最近的"。
|
||||||
|
func (c *RelevanceContext) TrimKeepRecent(n int) int {
|
||||||
|
c.mu.Lock()
|
||||||
|
if n < 1 {
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
if len(c.events) <= n {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
dropped := len(c.events) - n
|
||||||
|
kept := make([]*ContextEvent, n)
|
||||||
|
copy(kept, c.events[dropped:])
|
||||||
|
c.events = kept
|
||||||
|
c.dirty = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.save()
|
||||||
|
return dropped
|
||||||
|
}
|
||||||
|
|
||||||
func (c *RelevanceContext) Len() int {
|
func (c *RelevanceContext) Len() int {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|||||||
92
internal/agent/core/device_tool_auth_test.go
Normal file
92
internal/agent/core/device_tool_auth_test.go
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// 设备类工具的**授权闸**:设备指令类工具走的是工具面,而 AllowedOutputs 只作用于
|
||||||
|
// output_send__<通道> —— 不补闸的话"授权"对指令类完全无效(驻留子拿到
|
||||||
|
// device_ctl_cmdrun 就能指挥任意设备)。这里按目标设备的通道名 device/<id> 查同一道闸。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerFakeDevice 注册一台假设备,带一个"需要 device_id 的指令类工具"和一个无参枚举工具。
|
||||||
|
func registerFakeDevice(t *testing.T, a *Agent, name string, called *[]string) {
|
||||||
|
t.Helper()
|
||||||
|
dev := &mockOutputDevice{
|
||||||
|
name: name,
|
||||||
|
caps: agentIO.CapStructured,
|
||||||
|
tools: []agentIO.ToolDef{
|
||||||
|
{Name: "device_ctl_cmdrun", Description: "在设备上执行命令"},
|
||||||
|
{Name: "devicedetect", Description: "枚举设备"},
|
||||||
|
},
|
||||||
|
toolFn: func(tool string, args map[string]interface{}) (interface{}, error) {
|
||||||
|
if called != nil {
|
||||||
|
*called = append(*called, tool)
|
||||||
|
}
|
||||||
|
return "ok:" + tool, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := a.io.RegisterDevice(dev); err != nil {
|
||||||
|
t.Fatalf("注册测试设备 %s 失败: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deviceToolCall(name string, args map[string]interface{}) agentAPI.ToolCall {
|
||||||
|
return agentAPI.ToolCall{ID: "call_1", Name: name, Arguments: args}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完整授权(根 agent 默认):设备指令工具照常可用。
|
||||||
|
func TestDeviceToolAuth_RootHasFullGrant(t *testing.T) {
|
||||||
|
a := newPreemptAgent(t, newPreemptProvider())
|
||||||
|
var called []string
|
||||||
|
registerFakeDevice(t, a, "devicectl", &called)
|
||||||
|
|
||||||
|
got := a.executeToolCall(deviceToolCall("device_ctl_cmdrun", map[string]interface{}{
|
||||||
|
"device_id": "pc-1", "command": "ls",
|
||||||
|
}), "cli")
|
||||||
|
if !strings.Contains(got, "ok:device_ctl_cmdrun") {
|
||||||
|
t.Fatalf("根 agent 应可指挥任意设备,实际: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收窄授权(驻留子):只授权了 device/ok-1,指挥别的设备必须被拒,且**不落到设备**。
|
||||||
|
func TestDeviceToolAuth_NarrowedGrantRefusesOtherDevice(t *testing.T) {
|
||||||
|
a := newPreemptAgent(t, newPreemptProvider())
|
||||||
|
a.allowedOutputs = []string{"device/ok-1"}
|
||||||
|
var called []string
|
||||||
|
registerFakeDevice(t, a, "devicectl", &called)
|
||||||
|
|
||||||
|
got := a.executeToolCall(deviceToolCall("device_ctl_cmdrun", map[string]interface{}{
|
||||||
|
"device_id": "other-2", "command": "rm -rf /",
|
||||||
|
}), "cli")
|
||||||
|
if !strings.Contains(got, "未授权") {
|
||||||
|
t.Fatalf("未授权设备应被拒,实际: %s", got)
|
||||||
|
}
|
||||||
|
if len(called) != 0 {
|
||||||
|
t.Fatalf("被拒的调用不得落到设备,实际执行了 %v", called)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已授权的设备照常可用
|
||||||
|
got = a.executeToolCall(deviceToolCall("device_ctl_cmdrun", map[string]interface{}{
|
||||||
|
"device_id": "ok-1", "command": "ls",
|
||||||
|
}), "cli")
|
||||||
|
if !strings.Contains(got, "ok:device_ctl_cmdrun") {
|
||||||
|
t.Fatalf("已授权设备应可用,实际: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无参枚举类(devicedetect)不受闸门影响:它不指向具体设备。
|
||||||
|
func TestDeviceToolAuth_EnumerationNotGated(t *testing.T) {
|
||||||
|
a := newPreemptAgent(t, newPreemptProvider())
|
||||||
|
a.allowedOutputs = []string{"device/ok-1"}
|
||||||
|
var called []string
|
||||||
|
registerFakeDevice(t, a, "devicectl", &called)
|
||||||
|
|
||||||
|
got := a.executeToolCall(deviceToolCall("devicedetect", map[string]interface{}{}), "cli")
|
||||||
|
if !strings.Contains(got, "ok:devicedetect") {
|
||||||
|
t.Fatalf("枚举类工具不应被设备授权闸拦,实际: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -495,7 +495,6 @@ func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults
|
|||||||
|
|
||||||
func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) {
|
func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
a.currentOutputChannel = "_consolidation_"
|
|
||||||
|
|
||||||
stageCtx := a.stageCtxFromInput(input, evt.Source, "")
|
stageCtx := a.stageCtxFromInput(input, evt.Source, "")
|
||||||
stageCtx.Extra["output_channel"] = evt.OutputChannel
|
stageCtx.Extra["output_channel"] = evt.OutputChannel
|
||||||
|
|||||||
@ -13,25 +13,11 @@ import (
|
|||||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *Agent) eventLoop() {
|
// eventLoop 已由 scheduler.go 的 schedulerLoop 取代(M2)。
|
||||||
defer func() {
|
//
|
||||||
if r := recover(); r != nil {
|
// 原实现直接在 select 里处理 inputCh/selfInputCh,没有任何可枚举的队列、
|
||||||
log.Printf("[agent] eventLoop panic recovered: %v\n%s", r, debug.Stack())
|
// 无法承载优先级与抢占;现在任务先入就绪队列,由选择函数 pickTaskIndex 决定下一个。
|
||||||
time.Sleep(time.Second)
|
// 兼容性说明:M2 全部任务为 LevelBackground,因此行为等价于原先的 FIFO。
|
||||||
go a.eventLoop()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case evt := <-a.io.InputChan():
|
|
||||||
a.handleInput(evt)
|
|
||||||
case msg := <-a.selfInputCh:
|
|
||||||
a.handleSelfInput(msg)
|
|
||||||
case <-a.ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Agent) interceptLoop() {
|
func (a *Agent) interceptLoop() {
|
||||||
defer func() {
|
defer func() {
|
||||||
@ -64,43 +50,16 @@ func (a *Agent) interceptLoop() {
|
|||||||
clone.Payload["interrupt_source"] = evt.Source
|
clone.Payload["interrupt_source"] = evt.Source
|
||||||
clone.Payload["interrupt_channel"] = evt.OutputChannel
|
clone.Payload["interrupt_channel"] = evt.OutputChannel
|
||||||
|
|
||||||
a.llmMu.Lock()
|
// 决策交给调度器:requestPreempt 总会登记中断(进中断队列或 immediate,
|
||||||
hasActiveLLM := a.cancelLLM != nil
|
// 因而不会丢),仅当它会真抢占时才告诉我“该取消可取消的步骤”。
|
||||||
if hasActiveLLM {
|
// 本 goroutine 不碰任何帧——只写中断队列与让位信号。
|
||||||
a.cancelLLM()
|
//
|
||||||
log.Printf("[agent] LLM request cancelled by interrupt")
|
// 级别由来源声明(InjectOptions.Priority → payload["priority"]);
|
||||||
}
|
// 未声明一律 L1。L4(“立即打断”)只有内核级插件能声明,
|
||||||
a.llmMu.Unlock()
|
// 外部插件即便报了 L4 也会被夹到 L3;内核自身另有 raiseKernelInterrupt。
|
||||||
|
level := interruptLevel(evt, a.isKernelLevelSource(evt.Source))
|
||||||
if hasActiveLLM {
|
if a.sched.requestPreempt(clone, level) {
|
||||||
if a.currentOutputChannel == "_consolidation_" {
|
a.cancelCurrentLLM()
|
||||||
log.Printf("[agent] consolidation interrupted, re-injecting input for %s/%s", evt.Source, evt.OutputChannel)
|
|
||||||
a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
|
|
||||||
"content": text,
|
|
||||||
"interrupt": true,
|
|
||||||
"interrupt_source": evt.Source,
|
|
||||||
"interrupt_channel": evt.OutputChannel,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
select {
|
|
||||||
case a.interceptCh <- clone:
|
|
||||||
default:
|
|
||||||
log.Printf("[agent] intercept channel full, queuing input for %s", evt.Source)
|
|
||||||
a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
|
|
||||||
"content": text,
|
|
||||||
"interrupt": true,
|
|
||||||
"interrupt_source": evt.Source,
|
|
||||||
"interrupt_channel": evt.OutputChannel,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
|
|
||||||
"content": text,
|
|
||||||
"interrupt": true,
|
|
||||||
"interrupt_source": evt.Source,
|
|
||||||
"interrupt_channel": evt.OutputChannel,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case <-a.ctx.Done():
|
case <-a.ctx.Done():
|
||||||
@ -109,6 +68,20 @@ func (a *Agent) interceptLoop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cancelCurrentLLM 取消正在进行的 LLM 请求(若有)。
|
||||||
|
//
|
||||||
|
// 只有 LLM 流式步骤是可取消的;工具 RPC / ONNX / CAS 在 v1 是临界区,
|
||||||
|
// 取消对它们无效——让位信号会等它们自然结束后的安全点(设计文档 D2)。
|
||||||
|
func (a *Agent) cancelCurrentLLM() {
|
||||||
|
a.llmMu.Lock()
|
||||||
|
cancel := a.cancelLLM
|
||||||
|
a.llmMu.Unlock()
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
log.Printf("[agent] LLM request cancelled by preemption")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// channelConsolidation 标记记忆整理类自输入:无记忆路径处理,
|
// channelConsolidation 标记记忆整理类自输入:无记忆路径处理,
|
||||||
// 不写入对话上下文、不向任何输出通道 emit 响应。
|
// 不写入对话上下文、不向任何输出通道 emit 响应。
|
||||||
const channelConsolidation = "_consolidation_"
|
const channelConsolidation = "_consolidation_"
|
||||||
@ -122,22 +95,27 @@ type selfInputMsg struct {
|
|||||||
channel string
|
channel string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) handleSelfInput(msg selfInputMsg) {
|
// selfEvent 把内核自循环消息归一成输入事件。
|
||||||
|
func selfEvent(msg selfInputMsg) *agentIO.InputEvent {
|
||||||
if msg.channel == "" {
|
if msg.channel == "" {
|
||||||
msg.channel = channelConsolidation // 兼容空值:默认走整理路径
|
msg.channel = channelConsolidation // 兼容空值:默认走整理路径
|
||||||
}
|
}
|
||||||
a.processInput(&agentIO.InputEvent{
|
return &agentIO.InputEvent{
|
||||||
Source: "system",
|
Source: "system",
|
||||||
Type: "text",
|
Type: "text",
|
||||||
Payload: map[string]interface{}{"content": msg.text},
|
Payload: map[string]interface{}{"content": msg.text},
|
||||||
OutputChannel: msg.channel,
|
OutputChannel: msg.channel,
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) handleSelfInput(msg selfInputMsg) {
|
||||||
|
_, _ = a.runInputTask(selfEvent(msg))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) handleInput(evt *agentIO.InputEvent) {
|
func (a *Agent) handleInput(evt *agentIO.InputEvent) {
|
||||||
switch evt.Type {
|
switch evt.Type {
|
||||||
case "text", "image", "audio":
|
case "text", "image", "audio":
|
||||||
a.processInput(evt)
|
_, _ = a.runInputTask(evt)
|
||||||
|
|
||||||
case "event":
|
case "event":
|
||||||
log.Printf("[agent] event from %s: %v", evt.Source, evt.Payload)
|
log.Printf("[agent] event from %s: %v", evt.Source, evt.Payload)
|
||||||
@ -297,171 +275,51 @@ func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string,
|
|||||||
return blocks, alt
|
return blocks, alt
|
||||||
}
|
}
|
||||||
|
|
||||||
// processInput 是全部模态输入的唯一主干。
|
// emitSkippedReply 给被跳过任务的**同步**调用方一个终态。
|
||||||
//
|
//
|
||||||
// 文本、用户上传的图/音频、插件注入的多模态块走同一条路径,因此去重、
|
// 为什么要单独一条路径而不是复用 emitResponse:跳过意味着“我们没有处理这条输入”,
|
||||||
// no_memory、通道 Cleaner、中断语义、EventRawInput、媒体入 CAS、媒体记忆绑定
|
// 不应对外发 agent_output 事件(否则 WebUI 聊天记录会凭空多出一条空消息),
|
||||||
// 对所有模态一致——不会再出现「文本路径加了功能、媒体路径没跟上」。
|
// 但必须写 ResponseCh——否则 cli/clawhub 这类无超时的同步注入会永久挂起。
|
||||||
func (a *Agent) processInput(evt *agentIO.InputEvent) {
|
//
|
||||||
start := time.Now()
|
// 非阻塞写:ResponseCh 由同步调用方以 cap=1 创建,调用方超时离开后仍可写入。
|
||||||
|
func (a *Agent) emitSkippedReply(evt *agentIO.InputEvent, reason string) {
|
||||||
in, ok := a.resolveInput(evt)
|
if evt == nil || evt.ResponseCh == nil {
|
||||||
if !ok {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ch := evt.OutputChannel
|
||||||
// 去重按文本做:webui/GUI 断线重连会重放未确认消息。
|
if ch == "" {
|
||||||
// 带媒体时跳过——媒体输入的 alt 文案("[从 qq 收到了 image]")对不同图片
|
ch = evt.Source
|
||||||
// 是同一句,拿它去重会把连发的两张图误判成重复。
|
|
||||||
if len(in.blocks) == 0 && a.isDuplicateInput(evt.Source, in.text) {
|
|
||||||
log.Printf("[agent] dropped duplicate input from %s: %s", evt.Source, truncateStr(in.text, 60))
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
payload := map[string]interface{}{
|
||||||
a.currentOutputChannel = evt.OutputChannel
|
"content": "",
|
||||||
if a.currentOutputChannel == "" {
|
"request_id": evt.RequestID,
|
||||||
a.currentOutputChannel = evt.Source
|
"skipped": true,
|
||||||
|
"reason": reason,
|
||||||
}
|
}
|
||||||
|
select {
|
||||||
if evt.OutputChannel == "_consolidation_" {
|
case evt.ResponseCh <- &agentIO.OutputEvent{
|
||||||
a.processConsolidation(evt, in.text)
|
RequestID: evt.RequestID,
|
||||||
return
|
Target: evt.Source,
|
||||||
}
|
Type: "text",
|
||||||
|
Payload: payload,
|
||||||
// pendingMedia 让 describe_image / transcribe_audio / ocr_image 拿到本轮媒体的
|
Done: true,
|
||||||
// 原始 data/url,也是这三个工具是否出现在工具表里的开关。仅对用户直接上传成立
|
OutputChannel: ch,
|
||||||
//(payload 里才有 data/url);插件注入的是成品 block,取不到原始数据。
|
}:
|
||||||
if evt.Type == "image" || evt.Type == "audio" {
|
default:
|
||||||
a.pendingMedia = evt.Payload
|
|
||||||
defer func() { a.pendingMedia = nil }()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 媒体先落进 CAS。不存的后果是 ContextEvent.Input 只剩一句 alt 文本,
|
|
||||||
// base64 随 message 数组发给模型后就丢了。
|
|
||||||
if len(in.blocks) > 0 {
|
|
||||||
a.stageMediaDigests(a.captureBlockMedia(in.blocks, in.captureTool)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
noMemory := false
|
|
||||||
if v, ok := evt.Payload["no_memory"].(bool); ok {
|
|
||||||
noMemory = v
|
|
||||||
}
|
|
||||||
if !noMemory && a.io != nil {
|
|
||||||
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.NoMemory {
|
|
||||||
noMemory = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 工具提醒/中断(terminal_watch、timer 等)不是用户发言:
|
|
||||||
// 以 system 角色注入 LLM,且不写入用户对话履历。
|
|
||||||
isInterrupt, _ := evt.Payload["interrupt"].(bool)
|
|
||||||
a.mu.Lock()
|
|
||||||
a.interruptInput = isInterrupt
|
|
||||||
a.mu.Unlock()
|
|
||||||
if isInterrupt {
|
|
||||||
noMemory = true
|
|
||||||
}
|
|
||||||
|
|
||||||
stageCtx := a.stageCtxFromInput(in.text, evt.Source, "")
|
|
||||||
stageCtx.Extra["input_source"] = evt.Source
|
|
||||||
stageCtx.Extra["output_channel"] = evt.OutputChannel
|
|
||||||
if len(in.blocks) > 0 {
|
|
||||||
stageCtx.Extra["media_blocks"] = in.blocks
|
|
||||||
stageCtx.Extra["media_type"] = in.mediaType
|
|
||||||
}
|
|
||||||
if noMemory {
|
|
||||||
stageCtx.NoMemory = true
|
|
||||||
}
|
|
||||||
a.injectSourceContext(stageCtx, evt)
|
|
||||||
|
|
||||||
if a.runStage(sdk.StageOnInput, stageCtx) {
|
|
||||||
a.emitResponse(evt, *stageCtx.Response)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
input := stageCtx.RawMessage
|
|
||||||
|
|
||||||
// 计算层用的清洗文本(不改原文):通道 Cleaner 提取语义内容后用于向量化/提关键词
|
|
||||||
cleanInput := input
|
|
||||||
if a.io != nil {
|
|
||||||
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.Cleaner != nil {
|
|
||||||
cleanInput = chDef.Cleaner(input)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// upload_* 字段一并转发:webui 的 EventRawInput 订阅方靠它们还原附件卡片。
|
|
||||||
// 媒体路径此前把整个 payload 塞进 content(一个 map),订阅方按 string 断言
|
|
||||||
// 直接失败 → 用户发的图从不出现在聊天记录里。
|
|
||||||
rawPayload := map[string]interface{}{"content": input, "source": evt.Source}
|
|
||||||
for _, k := range []string{"upload_url", "upload_type", "upload_size", "upload_name"} {
|
|
||||||
if v, ok := evt.Payload[k]; ok {
|
|
||||||
rawPayload[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
a.publishEvent(events.EventRawInput, rawPayload)
|
|
||||||
|
|
||||||
archived := a.pruneOnInput(evt, cleanInput)
|
|
||||||
if archived > 0 {
|
|
||||||
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isInterrupt {
|
|
||||||
a.context.Append(ContextEvent{
|
|
||||||
Timestamp: start,
|
|
||||||
Source: evt.Source,
|
|
||||||
Input: input,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
response, toolsUsed, toolResults, err := a.process(input, stageCtx)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[agent] process %s error: %v", evt.Type, err)
|
|
||||||
resp := fmt.Sprintf("处理错误: %v", err)
|
|
||||||
a.emitResponse(evt, resp)
|
|
||||||
a.context.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Input: input, Response: resp})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
elapsed := time.Since(start)
|
|
||||||
log.Printf("[agent] %s from %s → response (%dms, tools=%v)", evt.Type, evt.Source, elapsed.Milliseconds(), toolsUsed)
|
|
||||||
|
|
||||||
// 本轮捕获的媒体一起挂到这条事件上:用户上传的、插件注入的,以及模型调
|
|
||||||
// multimodal_see_picture / see_video 时经 SetToolBlocks 注入的(后者在
|
|
||||||
// process() 里被捕获,纯文本输入也会有)。
|
|
||||||
turnEvt := ContextEvent{
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Source: "agent",
|
|
||||||
Input: cleanInput,
|
|
||||||
Response: response,
|
|
||||||
ToolsUsed: toolsUsed,
|
|
||||||
ToolResults: toolResults,
|
|
||||||
}
|
|
||||||
a.bindEventMedia(&turnEvt, a.drainMediaDigests())
|
|
||||||
a.context.Append(turnEvt)
|
|
||||||
|
|
||||||
a.emitResponse(evt, response)
|
|
||||||
|
|
||||||
if !stageCtx.NoMemory {
|
|
||||||
a.emitMemoryCandidate(evt.Source, cleanInput, response, toolResults, toolsUsed)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||||||
|
// 通道一律从**输入事件**推导(内核不持有"当前通道")。
|
||||||
|
ch := outputChannelOf(evt)
|
||||||
stageCtx := &sdk.StageContext{
|
stageCtx := &sdk.StageContext{
|
||||||
FinalText: response,
|
FinalText: response,
|
||||||
Phase: sdk.StageBeforeOutput,
|
Phase: sdk.StageBeforeOutput,
|
||||||
|
Extra: map[string]interface{}{"output_channel": ch},
|
||||||
}
|
}
|
||||||
a.runStage(sdk.StageBeforeOutput, stageCtx)
|
a.runStage(sdk.StageBeforeOutput, stageCtx)
|
||||||
response = stageCtx.FinalText
|
response = stageCtx.FinalText
|
||||||
|
|
||||||
ch := a.currentOutputChannel
|
|
||||||
if ch == "" {
|
|
||||||
ch = evt.OutputChannel
|
|
||||||
}
|
|
||||||
if ch == "" {
|
|
||||||
ch = evt.Source
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"content": response,
|
"content": response,
|
||||||
"request_id": evt.RequestID,
|
"request_id": evt.RequestID,
|
||||||
@ -496,33 +354,6 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
|||||||
a.runStage(sdk.StageAfterOutput, stageCtx)
|
a.runStage(sdk.StageAfterOutput, stageCtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) drainInterrupts() []string {
|
|
||||||
var out []string
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case evt := <-a.interceptCh:
|
|
||||||
if evt == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
text, _ := evt.Payload["content"].(string)
|
|
||||||
if text == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
source := evt.Source
|
|
||||||
if source == "" {
|
|
||||||
source = "unknown"
|
|
||||||
}
|
|
||||||
channel := evt.OutputChannel
|
|
||||||
if channel == "" {
|
|
||||||
channel = source
|
|
||||||
}
|
|
||||||
out = append(out, fmt.Sprintf("[打断消息][来源:%s][输出通道:%s] %s", source, channel, text))
|
|
||||||
default:
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pruneOnInput 按声明的上下文策略裁剪上下文,返回归档的事件数。
|
// pruneOnInput 按声明的上下文策略裁剪上下文,返回归档的事件数。
|
||||||
//
|
//
|
||||||
// 默认**不裁剪**:ContextPolicy 必须在注入点(payload 的 context_policy)
|
// 默认**不裁剪**:ContextPolicy 必须在注入点(payload 的 context_policy)
|
||||||
@ -539,6 +370,11 @@ func (a *Agent) pruneOnInput(evt *agentIO.InputEvent, cleanInput string) int {
|
|||||||
if a.context == nil || !a.pruneDeclared(evt) {
|
if a.context == nil || !a.pruneDeclared(evt) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
// **动态上下文**是父 agent 专属能力:轻量内核(驻留子)用传统上下文,
|
||||||
|
// 不做按相关度的裁剪与向 doc 记忆的归档(子也没有 doc 记忆)。
|
||||||
|
if a.isLightKernel() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
topK := a.maxContextSize - 1
|
topK := a.maxContextSize - 1
|
||||||
if topK < 1 {
|
if topK < 1 {
|
||||||
topK = 1
|
topK = 1
|
||||||
|
|||||||
@ -107,11 +107,14 @@ func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock)
|
|||||||
// seed 是调用方已持有的一等块(如 L2 文档的 Blocks),用于保持块身份;
|
// seed 是调用方已持有的一等块(如 L2 文档的 Blocks),用于保持块身份;
|
||||||
// 普通对话路径传 nil。blocks 是本次写入 L3 的块数。
|
// 普通对话路径传 nil。blocks 是本次写入 L3 的块数。
|
||||||
func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int, seed []memory.MemoryBlock) (entities, relations, blocks int, err error) {
|
func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int, seed []memory.MemoryBlock) (entities, relations, blocks int, err error) {
|
||||||
if a.memory == nil {
|
g := a.graphMem()
|
||||||
|
if g == nil {
|
||||||
return 0, 0, 0, fmt.Errorf("graph memory 未启用")
|
return 0, 0, 0, fmt.Errorf("graph memory 未启用")
|
||||||
}
|
}
|
||||||
if a.mediaStore == nil {
|
// 轻量内核(memory == nil,子只有图记忆)或没有媒体库时:只写图记忆。
|
||||||
ec, rc, cErr := a.memory.Commit(triples, sessionID, turnID)
|
// 写目标由 a.graph 决定 —— 根落 main,子落自己的 temp。
|
||||||
|
if a.memory == nil || a.mediaStore == nil {
|
||||||
|
ec, rc, cErr := g.Commit(triples, sessionID, turnID)
|
||||||
return ec, rc, 0, cErr
|
return ec, rc, 0, cErr
|
||||||
}
|
}
|
||||||
sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID)
|
sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID)
|
||||||
|
|||||||
167
internal/agent/core/inputch.go
Normal file
167
internal/agent/core/inputch.go
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// inputch 总览:**单工具多视图**。
|
||||||
|
//
|
||||||
|
// inputch 是**最基本的输入路由单位**(由插件注册,一个插件可注册多个)。
|
||||||
|
// 父 agent 需要能看清两件事:
|
||||||
|
// 1. 有哪些 inputch 已注册(谁注册的);
|
||||||
|
// 2. 它们是怎么划分的(各自划给了哪个 agent、容量多少)。
|
||||||
|
//
|
||||||
|
// 按用户要求做成**单工具多视图**(一个 `input_channels` 工具 + `view` 参数),
|
||||||
|
// 而不是一堆小工具 —— 视图切换比工具增殖更好用,也更省提示词预算。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *Agent) executeInputChannels(tc agentAPI.ToolCall) string {
|
||||||
|
view, _ := tc.Arguments["view"].(string)
|
||||||
|
view = strings.TrimSpace(view)
|
||||||
|
if view == "" {
|
||||||
|
view = "all"
|
||||||
|
}
|
||||||
|
name, _ := tc.Arguments["name"].(string)
|
||||||
|
|
||||||
|
all := a.io.InputChannels()
|
||||||
|
if len(all) == 0 {
|
||||||
|
return "没有任何已注册的 inputch。"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch view {
|
||||||
|
case "all":
|
||||||
|
return a.renderInputChannels(all, "全部已注册 inputch")
|
||||||
|
case "mine":
|
||||||
|
return a.renderInputChannels(a.channelRegistry().ListByOwner(string(a.id)),
|
||||||
|
"划给本 agent("+string(a.id)+")的 inputch")
|
||||||
|
case "unassigned":
|
||||||
|
return a.renderInputChannels(a.channelRegistry().ListByOwner(""),
|
||||||
|
"尚未划出的 inputch(可按需分配)")
|
||||||
|
case "by_agent":
|
||||||
|
return a.renderInputChannelsByAgent(all)
|
||||||
|
case "detail":
|
||||||
|
if name == "" {
|
||||||
|
return "view=detail 需要 name 参数(inputch 名)"
|
||||||
|
}
|
||||||
|
ch, ok := a.io.LookupInputChannel(name)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Sprintf("inputch %q 未注册", name)
|
||||||
|
}
|
||||||
|
return renderInputChannelDetail(ch)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("未知 view=%q;可用:all | mine | unassigned | by_agent | detail", view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) channelRegistry() *agentIO.ChannelRegistry { return a.io.ChannelRegistry() }
|
||||||
|
|
||||||
|
// renderInputChannels 渲染一组 inputch 的一行式概览。
|
||||||
|
func (a *Agent) renderInputChannels(list []agentIO.InputChannel, title string) string {
|
||||||
|
if len(list) == 0 {
|
||||||
|
return title + ":无"
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "%s(%d 个):", title, len(list))
|
||||||
|
for _, ch := range list {
|
||||||
|
fmt.Fprintf(&b, "\n - %s%s%s", ch.Name, pluginSuffix(ch), policySuffix(ch))
|
||||||
|
fmt.Fprintf(&b, "\n 归属: %s", ownerLabel(ch.Owner))
|
||||||
|
if ch.Capacity > 0 {
|
||||||
|
fmt.Fprintf(&b, " | 容量: %d", ch.Capacity)
|
||||||
|
}
|
||||||
|
if ch.Output != "" {
|
||||||
|
fmt.Fprintf(&b, " | 默认回程: %s", ch.Output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderInputChannelsByAgent 按归属分组("划分情况"总览)。
|
||||||
|
func (a *Agent) renderInputChannelsByAgent(all []agentIO.InputChannel) string {
|
||||||
|
byOwner := map[string][]agentIO.InputChannel{}
|
||||||
|
for _, ch := range all {
|
||||||
|
byOwner[ch.Owner] = append(byOwner[ch.Owner], ch)
|
||||||
|
}
|
||||||
|
owners := make([]string, 0, len(byOwner))
|
||||||
|
for o := range byOwner {
|
||||||
|
owners = append(owners, o)
|
||||||
|
}
|
||||||
|
sort.Strings(owners)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "inputch 划分情况(共 %d 个):", len(all))
|
||||||
|
for _, o := range owners {
|
||||||
|
names := make([]string, 0, len(byOwner[o]))
|
||||||
|
for _, ch := range byOwner[o] {
|
||||||
|
names = append(names, ch.Name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
fmt.Fprintf(&b, "\n - %s: %s", ownerLabel(o), strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderInputChannelDetail 渲染单个 inputch 的全部字段。
|
||||||
|
func renderInputChannelDetail(ch agentIO.InputChannel) string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "inputch: %s\n", ch.Name)
|
||||||
|
fmt.Fprintf(&b, " 注册插件: %s\n", orDash(ch.Plugin))
|
||||||
|
fmt.Fprintf(&b, " 归属 agent: %s\n", ownerLabel(ch.Owner))
|
||||||
|
if ch.Capacity > 0 {
|
||||||
|
fmt.Fprintf(&b, " 容量: %d\n", ch.Capacity)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&b, " 容量: 内核默认\n")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " 默认回程输出通道: %s\n", orDash(ch.Output))
|
||||||
|
fmt.Fprintf(&b, " 记忆策略: %s\n", policyLabel(ch))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluginSuffix(ch agentIO.InputChannel) string {
|
||||||
|
if ch.Plugin == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "(插件 " + ch.Plugin + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
// policySuffix 用短标记提示策略(详见 view=detail)。
|
||||||
|
func policySuffix(ch agentIO.InputChannel) string {
|
||||||
|
var m []string
|
||||||
|
if ch.Def.NoMemory {
|
||||||
|
m = append(m, "无记忆")
|
||||||
|
}
|
||||||
|
if ch.Def.Cleaner != nil {
|
||||||
|
m = append(m, "清洗")
|
||||||
|
}
|
||||||
|
if ch.Def.ContextPolicy != "" && ch.Def.ContextPolicy != "none" {
|
||||||
|
m = append(m, "裁剪:"+ch.Def.ContextPolicy)
|
||||||
|
}
|
||||||
|
if len(m) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " [" + strings.Join(m, "/") + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
func policyLabel(ch agentIO.InputChannel) string {
|
||||||
|
if s := policySuffix(ch); s != "" {
|
||||||
|
return strings.Trim(s, " []")
|
||||||
|
}
|
||||||
|
return "默认(记入记忆、不裁剪)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func ownerLabel(owner string) string {
|
||||||
|
if owner == "" {
|
||||||
|
return "未分配(根 agent/内核默认)"
|
||||||
|
}
|
||||||
|
return owner
|
||||||
|
}
|
||||||
|
|
||||||
|
func orDash(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
104
internal/agent/core/inputch_test.go
Normal file
104
internal/agent/core/inputch_test.go
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// inputch 总览工具(单工具多视图)的验收。
|
||||||
|
//
|
||||||
|
// 需求:父 agent 能看到**所有已注册的 inputch**以及**它们的划分情况**。
|
||||||
|
// 构筑方式:单工具(input_channels)+ 多视图(view=all|mine|unassigned|by_agent|detail)。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
)
|
||||||
|
|
||||||
|
func inputChToolCall(args map[string]interface{}) agentAPI.ToolCall {
|
||||||
|
return agentAPI.ToolCall{ID: "ic1", Name: "input_channels", Arguments: args}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInputChannelsTool_SingleToolMultipleViews(t *testing.T) {
|
||||||
|
a := newPreemptAgent(t, newPreemptProvider())
|
||||||
|
me := string(a.id)
|
||||||
|
|
||||||
|
// 同一个插件注册多个 inputch(最基本的输入路由单位);另一个插件再注册一个。
|
||||||
|
if err := a.io.RegisterInputChannelFrom("qq", "qq", agentIO.ChannelDef{NoMemory: true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := a.io.RegisterInputChannelFrom("qq", "qq/device-2", agentIO.ChannelDef{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := a.io.RegisterInputChannelFrom("sub", "sub/in", agentIO.ChannelDef{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 划分:qq 与 sub/in 归本 agent,qq/device-2 留未分配。
|
||||||
|
if err := a.io.AssignInputChannel("qq", me, 64); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := a.io.AssignInputChannel("sub/in", me, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// view=all(默认):全部已注册,且带归属插件。
|
||||||
|
all := a.executeInputChannels(inputChToolCall(nil))
|
||||||
|
for _, want := range []string{"qq", "qq/device-2", "sub/in", "插件 qq", "插件 sub", "无记忆"} {
|
||||||
|
if !strings.Contains(all, want) {
|
||||||
|
t.Fatalf("view=all 缺少 %q:\n%s", want, all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// view=mine:只有划给本 agent 的。
|
||||||
|
mine := a.executeInputChannels(inputChToolCall(map[string]interface{}{"view": "mine"}))
|
||||||
|
if !strings.Contains(mine, "qq") || !strings.Contains(mine, "sub/in") {
|
||||||
|
t.Fatalf("view=mine 应含 qq 与 sub/in:\n%s", mine)
|
||||||
|
}
|
||||||
|
if strings.Contains(mine, "qq/device-2") {
|
||||||
|
t.Fatalf("view=mine 不应含未分配的 qq/device-2:\n%s", mine)
|
||||||
|
}
|
||||||
|
|
||||||
|
// view=unassigned:只有尚未划出的。
|
||||||
|
un := a.executeInputChannels(inputChToolCall(map[string]interface{}{"view": "unassigned"}))
|
||||||
|
if !strings.Contains(un, "qq/device-2") {
|
||||||
|
t.Fatalf("view=unassigned 应含 qq/device-2:\n%s", un)
|
||||||
|
}
|
||||||
|
if strings.Contains(un, "sub/in") {
|
||||||
|
t.Fatalf("view=unassigned 不应含已划分的 sub/in:\n%s", un)
|
||||||
|
}
|
||||||
|
|
||||||
|
// view=by_agent:划分情况总览(按归属分组)。
|
||||||
|
byAgent := a.executeInputChannels(inputChToolCall(map[string]interface{}{"view": "by_agent"}))
|
||||||
|
if !strings.Contains(byAgent, me+":") {
|
||||||
|
t.Fatalf("view=by_agent 应列出归属 %q:\n%s", me, byAgent)
|
||||||
|
}
|
||||||
|
if !strings.Contains(byAgent, "未分配") {
|
||||||
|
t.Fatalf("view=by_agent 应列出未分配一组:\n%s", byAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// view=detail:单个 inputch 的全字段(容量 / 策略 / 回程 / 归属)。
|
||||||
|
detail := a.executeInputChannels(inputChToolCall(map[string]interface{}{"view": "detail", "name": "qq"}))
|
||||||
|
for _, want := range []string{"inputch: qq", "注册插件: qq", "容量: 64", "无记忆", me} {
|
||||||
|
if !strings.Contains(detail, want) {
|
||||||
|
t.Fatalf("view=detail 缺少 %q:\n%s", want, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if miss := a.executeInputChannels(inputChToolCall(map[string]interface{}{"view": "detail", "name": "nope"})); !strings.Contains(miss, "未注册") {
|
||||||
|
t.Fatalf("detail 查未注册的 inputch 应明确报错:%s", miss)
|
||||||
|
}
|
||||||
|
if noName := a.executeInputChannels(inputChToolCall(map[string]interface{}{"view": "detail"})); !strings.Contains(noName, "需要 name") {
|
||||||
|
t.Fatalf("detail 缺 name 应提示:%s", noName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未知视图必须报错并列出可用值(不要把拼错静默当成默认视图)。
|
||||||
|
bad := a.executeInputChannels(inputChToolCall(map[string]interface{}{"view": "whatever"}))
|
||||||
|
if !strings.Contains(bad, "未知 view") || !strings.Contains(bad, "by_agent") {
|
||||||
|
t.Fatalf("未知 view 应报错并列出可用值:%s", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一个 inputch 都没有时应给出明确说明,而不是空串。
|
||||||
|
func TestInputChannelsTool_NoChannels(t *testing.T) {
|
||||||
|
a := newPreemptAgent(t, newPreemptProvider())
|
||||||
|
if out := a.executeInputChannels(inputChToolCall(nil)); !strings.Contains(out, "没有任何已注册") {
|
||||||
|
t.Fatalf("空登记表应明确说明:%q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
169
internal/agent/core/lightprofile_test.go
Normal file
169
internal/agent/core/lightprofile_test.go
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// N2c 验收:**轻量内核 profile**。
|
||||||
|
//
|
||||||
|
// 设计 docs/zh/resident-subagent-design.md §16.0(窄接口 + nil 即禁用)与 §5.5。
|
||||||
|
//
|
||||||
|
// 轻量内核(驻留子)的记忆装配:
|
||||||
|
// - graph = *memory.LightMemory(读 temp∪main,只写 temp)
|
||||||
|
// - memory = nil ⇒ 既有的 `if a.memory != nil` 关卡自动禁掉**全部**整理面:
|
||||||
|
// 记忆整理流水线(distill.go 的 archive/review/merge 循环)、记忆块与媒体桥
|
||||||
|
// (graphmedia.go / medialoop.go)、记忆整理工具(merge/delete/purge/edit/block_merge)
|
||||||
|
//
|
||||||
|
// 因此这里要钉住四件事:
|
||||||
|
// ① 子的写入只落 temp,主库不受影响;
|
||||||
|
// ② 子的读是并集(看得到主库 + 自己的 temp);
|
||||||
|
// ③ 整理类工具**不进子的工具表**;
|
||||||
|
// ④ 即便被直调,整理类操作也明确报"轻量内核不支持"(纵深防御,不静默降级)。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newLightAgent 构造一个轻量内核 agent(驻留子形态):有 LightMemory,没有整理面。
|
||||||
|
func newLightAgent(t *testing.T, main *memory.GraphDB, tempPath string) *Agent {
|
||||||
|
t.Helper()
|
||||||
|
light, err := memory.NewLightMemory(main, tempPath, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a := New(AgentConfig{
|
||||||
|
ID: "sub-1",
|
||||||
|
Provider: &scriptProvider{},
|
||||||
|
ProviderManager: agentAPI.NewProviderManager(),
|
||||||
|
IO: agentIO.NewIOManager(),
|
||||||
|
StageHost: NewStageHost(),
|
||||||
|
LightMemory: light, // 轻量内核:只给图记忆共同面
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { light.Close() })
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLightProfile_MemoryFaceWiring(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
main, err := memory.NewGraphDB(filepath.Join(dir, "main.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer main.Close()
|
||||||
|
if _, _, err := main.Commit([]memory.Triple{
|
||||||
|
{Subject: "主记忆实体", Relation: "属于", Object: "主库"},
|
||||||
|
}, "sess", 1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a := newLightAgent(t, main, filepath.Join(dir, "sub.db"))
|
||||||
|
|
||||||
|
// ① 整理面必须为 nil —— 这正是"nil 即禁用"的开关。
|
||||||
|
if a.memory != nil {
|
||||||
|
t.Fatal("轻量内核不该有整理面(a.memory 必须为 nil)")
|
||||||
|
}
|
||||||
|
if a.graphMem() == nil {
|
||||||
|
t.Fatal("轻量内核必须有图记忆共同面")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 写入只落 temp:主库不得出现子才知道的实体。
|
||||||
|
if _, _, _, err := a.commitTriplesWithMedia([]memory.Triple{
|
||||||
|
{Subject: "子独有实体", Relation: "来自", Object: "子的temp"},
|
||||||
|
}, "sess", 1, nil); err != nil {
|
||||||
|
t.Fatalf("子的写入应成功(落 temp): %v", err)
|
||||||
|
}
|
||||||
|
mainRes, err := main.Recall([]string{"子独有实体"}, nil, 1, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, e := range mainRes.Entities {
|
||||||
|
if e.Name == "子独有实体" {
|
||||||
|
t.Fatalf("子的写入不该进主库:%v", e.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 读是并集:主库的实体与 temp 的实体都要看得到。
|
||||||
|
got := a.executeMemoryTool(agentAPI.ToolCall{
|
||||||
|
ID: "c1", Name: "memory_recall",
|
||||||
|
Arguments: map[string]interface{}{"query_intent": "主记忆实体,子独有实体"},
|
||||||
|
})
|
||||||
|
if !strings.Contains(got, "主记忆实体") {
|
||||||
|
t.Fatalf("子应看得到主记忆:%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "子独有实体") {
|
||||||
|
t.Fatalf("子应看得到自己的 temp:%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLightProfile_OrganizeToolsAbsentAndRefused(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
main, err := memory.NewGraphDB(filepath.Join(dir, "main.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer main.Close()
|
||||||
|
|
||||||
|
a := newLightAgent(t, main, filepath.Join(dir, "sub.db"))
|
||||||
|
|
||||||
|
// ① 整理类工具不进子的工具表(不是进去再报不可用)。
|
||||||
|
names := toolNames(a)
|
||||||
|
for _, banned := range []string{
|
||||||
|
"memory_merge", "memory_delete_entity", "memory_block_merge",
|
||||||
|
"memory_purge", "memory_edit",
|
||||||
|
} {
|
||||||
|
if hasTool(names, banned) {
|
||||||
|
t.Fatalf("轻量内核不该声明整理类工具 %s:%v", banned, names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 对照:共同面/无关能力照常在。
|
||||||
|
if !hasTool(names, "input_channels") {
|
||||||
|
t.Fatalf("轻量内核仍应有共同面工具:%v", names)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 纵深防御:即便被直调,整理类操作也必须明确报"轻量内核不支持"。
|
||||||
|
for _, tool := range []string{
|
||||||
|
"memory_merge", "memory_delete_entity", "memory_block_merge",
|
||||||
|
"memory_purge", "memory_edit", "memory_introspect",
|
||||||
|
} {
|
||||||
|
got := a.executeMemoryTool(agentAPI.ToolCall{
|
||||||
|
ID: "x", Name: tool,
|
||||||
|
Arguments: map[string]interface{}{
|
||||||
|
"name": "任意", "source": "a", "target": "b", "criteria": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !strings.Contains(got, "轻量内核") {
|
||||||
|
t.Fatalf("%s 在轻量内核里必须明确报不支持,实际 %q", tool, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对照:完整内核(根 agent)仍有整理面与整理工具。
|
||||||
|
func TestFullProfile_KeepsOrganizeFace(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
main, err := memory.NewGraphDB(filepath.Join(dir, "main.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer main.Close()
|
||||||
|
|
||||||
|
a := New(AgentConfig{
|
||||||
|
ID: "root",
|
||||||
|
Provider: &scriptProvider{},
|
||||||
|
ProviderManager: agentAPI.NewProviderManager(),
|
||||||
|
IO: agentIO.NewIOManager(),
|
||||||
|
StageHost: NewStageHost(),
|
||||||
|
Memory: main,
|
||||||
|
})
|
||||||
|
if a.memory == nil {
|
||||||
|
t.Fatal("根 agent 必须有整理面")
|
||||||
|
}
|
||||||
|
if a.graphMem() == nil {
|
||||||
|
t.Fatal("根 agent 必须有图记忆共同面")
|
||||||
|
}
|
||||||
|
names := toolNames(a)
|
||||||
|
if !hasTool(names, "memory_merge") {
|
||||||
|
t.Fatalf("根 agent 应保留整理类工具:%v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -138,7 +138,7 @@ func (a *Agent) embedMediaOnIngest(digest, mime string, data []byte) {
|
|||||||
//
|
//
|
||||||
// 为何要缓存而不是当场建块:媒体在 process() 执行期间被捕获,而承载它的
|
// 为何要缓存而不是当场建块:媒体在 process() 执行期间被捕获,而承载它的
|
||||||
// ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
|
// ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
|
||||||
// 与既有的 a.pendingMedia 同一手法(都在 a.mu 保护下)。
|
// 与既有的 a.pendingMedia 同一手法(均由 schedulerLoop goroutine 独占读写)。
|
||||||
func (a *Agent) stageMediaDigests(digests ...string) {
|
func (a *Agent) stageMediaDigests(digests ...string) {
|
||||||
if len(digests) == 0 {
|
if len(digests) == 0 {
|
||||||
return
|
return
|
||||||
@ -197,7 +197,7 @@ func mediaLabel(it *media.Item) string {
|
|||||||
//
|
//
|
||||||
// 沿用 document.Store 的 doc_<unixnano> 手法(同一份代码库里保持一致,
|
// 沿用 document.Store 的 doc_<unixnano> 手法(同一份代码库里保持一致,
|
||||||
// 也避免为此引入 uuid 依赖)。纳秒精度足够:同一 Agent 的事件由
|
// 也避免为此引入 uuid 依赖)。纳秒精度足够:同一 Agent 的事件由
|
||||||
// a.mu 串行化 Append,不存在同纳秒两条。
|
// schedulerLoop 单 goroutine 串行 Append,不存在同纳秒两条。
|
||||||
func newEventID() string {
|
func newEventID() string {
|
||||||
return fmt.Sprintf("evt_%d", time.Now().UnixNano())
|
return fmt.Sprintf("evt_%d", time.Now().UnixNano())
|
||||||
}
|
}
|
||||||
|
|||||||
56
internal/agent/core/memoryface.go
Normal file
56
internal/agent/core/memoryface.go
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// 图记忆的**共同面**(设计 docs/zh/resident-subagent-design.md §16.0)。
|
||||||
|
//
|
||||||
|
// 只有两个方法 —— 因为按调用方实测分类后,`Agent.memory` 的 42 处使用里,
|
||||||
|
// 真正"根与子都要"的只有:
|
||||||
|
//
|
||||||
|
// Recall 读(上下文检索 / memory_recall)
|
||||||
|
// Commit 写(自动写入路径的图部分 / memory_commit)
|
||||||
|
//
|
||||||
|
// 其余 40 处全是**主 agent 整理记忆**与**记忆整理流水线**:
|
||||||
|
// - 记忆整理流水线:distill.go 的 archive/review/merge 循环
|
||||||
|
// - 记忆块 + 媒体桥:graphmedia.go / medialoop.go
|
||||||
|
// - 记忆整理工具:memory_merge / memory_delete_entity / memory_block_merge /
|
||||||
|
// memory_purge / memory_edit / memory_introspect
|
||||||
|
//
|
||||||
|
// 所以驻留子的轻量内核**不该有那些代码路径**:它用 `*memory.LightMemory` 接上 `graph`,
|
||||||
|
// 而 `a.memory`(整理面)保持 nil —— 既有的 22 处 `if a.memory != nil` 关卡
|
||||||
|
// 会自动把整理面全部禁掉,不需要写"每个方法都返回错误"的受限包装。
|
||||||
|
|
||||||
|
import "gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
|
|
||||||
|
// isLightKernel 报告本 agent 是不是**轻量内核**(驻留子)。
|
||||||
|
//
|
||||||
|
// 轻量内核 = 传统上下文 + 图记忆(作用域化):它**没有**动态上下文能力
|
||||||
|
// (预算裁剪 / 按相关度裁剪并向 doc 记忆归档),那些是父 agent 专属的。
|
||||||
|
func (a *Agent) isLightKernel() bool { return a != nil && a.parentID != "" }
|
||||||
|
|
||||||
|
// contextTokenBudget 返回拼装时间线时可用的 token 预算。
|
||||||
|
//
|
||||||
|
// 完整内核:用动态上下文算出来的 ContextTokens(按相关度/预算**策略性**裁时间线)
|
||||||
|
// 轻量内核:**用整个窗口** —— 传统上下文只受"模型能收多少"这个**硬上限**约束,
|
||||||
|
// 不做任何策略性裁剪(不按相关度挑、不向 doc 记忆归档);
|
||||||
|
// 而且在撞到硬上限之前,contextfull(90% 窗口)已按 L4 上报父 agent
|
||||||
|
// 决策(压缩/回收/销毁)—— 丢事件的决定权在父,不在内核。
|
||||||
|
func (a *Agent) contextTokenBudget(b TokenBudget) int {
|
||||||
|
if a.isLightKernel() {
|
||||||
|
if b.MaxContext > 0 {
|
||||||
|
return b.MaxContext
|
||||||
|
}
|
||||||
|
return defaultMaxContextTokens
|
||||||
|
}
|
||||||
|
return b.ContextTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// GraphMemory 是任意 agent 都能用的图记忆面。
|
||||||
|
//
|
||||||
|
// 实现者:
|
||||||
|
// - 根 agent:直接就是 *memory.GraphDB
|
||||||
|
// - 驻留子:*memory.LightMemory(读 temp∪main,只写 temp)
|
||||||
|
type GraphMemory interface {
|
||||||
|
// Recall 按关键词/种子实体召回(子的实现是两空间并集)。
|
||||||
|
Recall(keywords []string, seedEntities []string, depth int, sessionFilter string) (*memory.RecallResult, error)
|
||||||
|
// Commit 写入三元组(子的实现只落自己的 temp 空间)。
|
||||||
|
Commit(triples []memory.Triple, sessionID string, turnID int) (int, int, error)
|
||||||
|
}
|
||||||
@ -16,6 +16,11 @@ func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
|
|||||||
if channel == "" || payload == "" || rawType == "" {
|
if channel == "" || payload == "" || rawType == "" {
|
||||||
return "工具名称格式: output_send__{channel},payload 和 type 不能为空"
|
return "工具名称格式: output_send__{channel},payload 和 type 不能为空"
|
||||||
}
|
}
|
||||||
|
// 授权闸(纵深防御):模型可能凭名字直接调未授权的输出门。
|
||||||
|
if !a.IsOutputAllowed(channel) {
|
||||||
|
return fmt.Sprintf("通道 [%s] 未授权给本 agent。可用通道见 output_list_channels", channel)
|
||||||
|
}
|
||||||
|
|
||||||
meta, _ := tc.Arguments["meta"].(string)
|
meta, _ := tc.Arguments["meta"].(string)
|
||||||
|
|
||||||
caps := a.io.GetChannelCapabilities(channel)
|
caps := a.io.GetChannelCapabilities(channel)
|
||||||
@ -146,7 +151,14 @@ func (a *Agent) executeOutputListChannels() string {
|
|||||||
if ch.OutputCaps == 0 {
|
if ch.OutputCaps == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
parts = append(parts, fmt.Sprintf(" - %s: [%s] %s", ch.Name, ch.OutputCaps.String(), ch.Description))
|
if !a.IsOutputAllowed(ch.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
line := fmt.Sprintf(" - %s: [%s] %s", ch.Name, ch.OutputCaps.String(), ch.Description)
|
||||||
|
if t, ok := a.ResolveOutputTarget(ch.Name); ok {
|
||||||
|
line += fmt.Sprintf("(目标: %s / inputch %s)", orDash(t.AgentID), orDash(t.InputCh))
|
||||||
|
}
|
||||||
|
parts = append(parts, line)
|
||||||
for _, t := range ch.Tools {
|
for _, t := range ch.Tools {
|
||||||
parts = append(parts, fmt.Sprintf(" 工具: %s - %s", t.Name, t.Description))
|
parts = append(parts, fmt.Sprintf(" 工具: %s - %s", t.Name, t.Description))
|
||||||
}
|
}
|
||||||
|
|||||||
138
internal/agent/core/output_grant_test.go
Normal file
138
internal/agent/core/output_grant_test.go
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// N1b:输出通道授权集合 + 输出通道 → 目标 agent 的 inputch 解析。
|
||||||
|
//
|
||||||
|
// 设计依据 docs/zh/resident-subagent-design.md §4.4(通道分配:不对称)与 R2
|
||||||
|
// (插件与工具由父授权,**默认完整授权**)。
|
||||||
|
//
|
||||||
|
// 三个过滤点必须一致,否则会出现"列表里看不到、但按名字还能调"的裂缝:
|
||||||
|
// ① 工具表(不为未授权的通道生成 output_send__X)
|
||||||
|
// ② 列表工具(output_list_channels 只列授权的)
|
||||||
|
// ③ 调用点(凭名字直调也必须被拒 —— 纵深防御)
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerFakeOutput 注册一个假的输出通道(device 通道)。
|
||||||
|
func registerFakeOutput(t *testing.T, a *Agent, name string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := a.io.RegisterDevice(&mockOutputDevice{name: name, caps: agentIO.CapText}); err != nil {
|
||||||
|
t.Fatalf("注册测试通道 %s 失败: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolNames(a *Agent) []string {
|
||||||
|
var names []string
|
||||||
|
for _, t := range a.buildToolDefs() {
|
||||||
|
m, ok := t.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fn, _ := m["function"].(map[string]interface{})
|
||||||
|
if n, _ := fn["name"].(string); n != "" {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasTool(names []string, want string) bool {
|
||||||
|
for _, n := range names {
|
||||||
|
if n == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认(未配置白名单)= 完整授权:所有输出通道都能用。
|
||||||
|
func TestOutputGrant_DefaultIsFull(t *testing.T) {
|
||||||
|
a := newPreemptAgent(t, newPreemptProvider())
|
||||||
|
registerFakeOutput(t, a, "qq")
|
||||||
|
registerFakeOutput(t, a, "webui")
|
||||||
|
|
||||||
|
if !a.IsOutputAllowed("qq") || !a.IsOutputAllowed("webui") {
|
||||||
|
t.Fatal("默认应为完整授权")
|
||||||
|
}
|
||||||
|
names := toolNames(a)
|
||||||
|
if !hasTool(names, "output_send__qq") || !hasTool(names, "output_send__webui") {
|
||||||
|
t.Fatalf("默认完整授权下应生成全部输出门,实际 %v", names)
|
||||||
|
}
|
||||||
|
if out := a.executeOutputListChannels(); !strings.Contains(out, "qq") || !strings.Contains(out, "webui") {
|
||||||
|
t.Fatalf("默认完整授权下列表应含全部通道:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 白名单收窄:三个过滤点必须一致。
|
||||||
|
func TestOutputGrant_NarrowedWhitelist(t *testing.T) {
|
||||||
|
a := newPreemptAgent(t, newPreemptProvider())
|
||||||
|
a.allowedOutputs = []string{"webui"} // 模拟父创建子时收窄
|
||||||
|
registerFakeOutput(t, a, "qq")
|
||||||
|
registerFakeOutput(t, a, "webui")
|
||||||
|
|
||||||
|
if a.IsOutputAllowed("qq") {
|
||||||
|
t.Fatal("白名单外的通道不应被授权")
|
||||||
|
}
|
||||||
|
if !a.IsOutputAllowed("webui") {
|
||||||
|
t.Fatal("白名单内的通道应被授权")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ① 工具表
|
||||||
|
names := toolNames(a)
|
||||||
|
if hasTool(names, "output_send__qq") {
|
||||||
|
t.Fatalf("未授权的通道不该生成输出门工具:%v", names)
|
||||||
|
}
|
||||||
|
if !hasTool(names, "output_send__webui") {
|
||||||
|
t.Fatalf("已授权的通道应生成输出门工具:%v", names)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 列表工具
|
||||||
|
out := a.executeOutputListChannels()
|
||||||
|
if strings.Contains(out, "qq") {
|
||||||
|
t.Fatalf("列表不应含未授权通道:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "webui") {
|
||||||
|
t.Fatalf("列表应含已授权通道:\n%s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 调用点(凭名字直调)
|
||||||
|
got := a.executeOutputSendTool(agentAPI.ToolCall{
|
||||||
|
ID: "c1", Name: "output_send__qq",
|
||||||
|
Arguments: map[string]interface{}{"payload": "hi", "type": "text"},
|
||||||
|
})
|
||||||
|
if !strings.Contains(got, "未授权") {
|
||||||
|
t.Fatalf("未授权的输出门必须被拒,实际 %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输出通道 → 目标 agent 的 inputch 的解析("输出可寻址到具体 agent")。
|
||||||
|
func TestOutputTarget_Resolution(t *testing.T) {
|
||||||
|
a := newPreemptAgent(t, newPreemptProvider())
|
||||||
|
reg := a.io.ChannelRegistry()
|
||||||
|
|
||||||
|
if err := reg.BindOutputTarget("to-child-1", "child-1", "sub/in"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tgt, ok := a.ResolveOutputTarget("to-child-1")
|
||||||
|
if !ok || tgt.AgentID != "child-1" || tgt.InputCh != "sub/in" {
|
||||||
|
t.Fatalf("解析结果=%+v ok=%v", tgt, ok)
|
||||||
|
}
|
||||||
|
// 未登记的输出通道由传输层处理(如 qq/webui 这类 device 通道)。
|
||||||
|
if _, ok := a.ResolveOutputTarget("qq"); ok {
|
||||||
|
t.Fatal("未登记目标解析的输出通道不应解析出 agent")
|
||||||
|
}
|
||||||
|
if err := reg.BindOutputTarget("", "x", "y"); err == nil {
|
||||||
|
t.Fatal("空输出通道名应报错")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表工具在已登记时带出目标,便于模型知道"这条通道发给谁"。
|
||||||
|
registerFakeOutput(t, a, "to-child-1")
|
||||||
|
if out := a.executeOutputListChannels(); !strings.Contains(out, "child-1") {
|
||||||
|
t.Fatalf("已登记目标的输出通道应在列表里标出目标:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,12 +7,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// continuationPlaceholder 是工具轮之后补的 user 占位内容。
|
// continuationPlaceholder 是工具轮之后补的 user 占位内容。
|
||||||
@ -99,420 +97,6 @@ func dropContinuationPlaceholders(msgs []agentAPI.Message) []agentAPI.Message {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response string, toolsUsed []string, toolResults []ToolResultItem, err error) {
|
|
||||||
a.mu.Lock()
|
|
||||||
defer a.mu.Unlock()
|
|
||||||
|
|
||||||
if a.provider == nil {
|
|
||||||
return "", nil, nil, fmt.Errorf("agent: no LLM provider configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
budget := ComputeTokenBudget(a.provider, a.systemPrompt)
|
|
||||||
|
|
||||||
memContext := a.buildMemoryContext(input, budget.MemoryTokens)
|
|
||||||
sysPrompt := a.buildSystemPrompt(memContext, input)
|
|
||||||
tools := a.buildToolDefs()
|
|
||||||
|
|
||||||
msgs := a.buildMessages(sysPrompt, input, budget.ContextTokens)
|
|
||||||
// 工具提醒(interrupt):以 system 角色注入,不让模型误认为用户发言
|
|
||||||
if a.interruptInput {
|
|
||||||
last := msgs[len(msgs)-1]
|
|
||||||
last.Role = "system"
|
|
||||||
last.Content = "[中断消息] " + last.Content
|
|
||||||
msgs[len(msgs)-1] = last
|
|
||||||
a.interruptInput = false
|
|
||||||
}
|
|
||||||
if blocks, ok := stageCtx.Extra["media_blocks"].([]agentAPI.ContentBlock); ok && len(blocks) > 0 {
|
|
||||||
if len(msgs) > 0 {
|
|
||||||
msgs[len(msgs)-1].Blocks = blocks
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("[agent] tool call loop start, max_ctx=%d target=%d fixed=%d mem=%d ctx=%d %d tools, %d events, personality=%t, docs=%d",
|
|
||||||
budget.MaxContext, budget.TargetUsage, budget.FixedTokens, budget.MemoryTokens, budget.ContextTokens,
|
|
||||||
len(tools), a.context.Len(),
|
|
||||||
a.personality != nil && a.personality.Content != "",
|
|
||||||
a.docStoreSize())
|
|
||||||
|
|
||||||
if a.runStage(sdk.StagePreAction, stageCtx) {
|
|
||||||
return *stageCtx.Response, toolsUsed, toolResults, nil
|
|
||||||
}
|
|
||||||
if len(stageCtx.ContextMsgs) > 0 {
|
|
||||||
for _, m := range stageCtx.ContextMsgs {
|
|
||||||
role, _ := m["role"].(string)
|
|
||||||
content, _ := m["content"].(string)
|
|
||||||
if role != "" {
|
|
||||||
msgs = append(msgs, agentAPI.Message{Role: role, Content: content})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// lastBatchReplyOnly 记录上一批工具调用是否全部是输出通道发送。
|
|
||||||
lastBatchReplyOnly := false
|
|
||||||
|
|
||||||
for turn := 0; ; turn++ {
|
|
||||||
for _, interrupt := range a.drainInterrupts() {
|
|
||||||
msgs = append(msgs, agentAPI.Message{
|
|
||||||
Role: "system",
|
|
||||||
Content: "[中断消息] " + interrupt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// zen 兼容网关要求请求的最后一条消息必须是 user(thinking 续写模式校验),
|
|
||||||
// 工具轮产出的 tool/assistant 消息作结尾会被 400 拒绝,故补一条 user 占位。
|
|
||||||
// 注意:仅当尾部确为工具轮产物(assistant/tool)时才补位;首轮 system 上下文结尾不补,
|
|
||||||
// 否则会错误覆盖实际用户输入(如 injectSourceContext 追加的 system 说明)。
|
|
||||||
//
|
|
||||||
// 补位前先移除前面轮次插入的同类占位,保证占位**不随轮次线性累积**——
|
|
||||||
// 占位是核心插的传输层附加物,不是用户发言,不该在 prompt 里叠成 N 条。
|
|
||||||
//
|
|
||||||
// 文案分情况:上一批全是 output_send__* 时不能说“继续”,详见
|
|
||||||
// replyDeliveredPlaceholder 的说明。
|
|
||||||
msgs = dropContinuationPlaceholders(msgs)
|
|
||||||
if last := msgs[len(msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
|
|
||||||
msgs = append(msgs, agentAPI.Message{
|
|
||||||
Role: "user",
|
|
||||||
Content: continuationFor(lastBatchReplyOnly),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
req := &agentAPI.CompletionRequest{
|
|
||||||
Messages: msgs,
|
|
||||||
MaxTokens: 4096,
|
|
||||||
Tools: tools,
|
|
||||||
ToolChoice: "auto",
|
|
||||||
DisableThinking: !a.thinkingEnabled,
|
|
||||||
}
|
|
||||||
|
|
||||||
var providers []agentAPI.Provider
|
|
||||||
if a.providerManager != nil {
|
|
||||||
// 精确模型名走 byModel 路由;AUTO/空走优先级链
|
|
||||||
var allProviders []agentAPI.Provider
|
|
||||||
if req.Model != "" && !strings.EqualFold(req.Model, "AUTO") {
|
|
||||||
allProviders = a.providerManager.ResolveForModel(req.Model)
|
|
||||||
} else {
|
|
||||||
allProviders = a.providerManager.OrderedProviders()
|
|
||||||
}
|
|
||||||
providers = make([]agentAPI.Provider, 0, len(allProviders))
|
|
||||||
for _, p := range allProviders {
|
|
||||||
if a.providerManager.IsAvailable(p.Name()) {
|
|
||||||
providers = append(providers, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(providers) == 0 {
|
|
||||||
providers = []agentAPI.Provider{a.provider}
|
|
||||||
}
|
|
||||||
var resp *agentAPI.CompletionResponse
|
|
||||||
var llmErr error
|
|
||||||
|
|
||||||
for pi, fbProvider := range providers {
|
|
||||||
if pi > 0 {
|
|
||||||
log.Printf("[agent] LLM fallback: trying provider %q (fallback #%d/%d)",
|
|
||||||
fbProvider.Name(), pi, len(providers)-1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同源瞬时错误重试:网关瞬断(502/503/504/429/网络抖动)通常秒级恢复,
|
|
||||||
// 直接跳下一个 provider(或直接报错)会丢掉本可成功的请求。
|
|
||||||
// 凭证错误(401/403)与用户中断不重试。
|
|
||||||
const maxAttempts = 2
|
|
||||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
|
||||||
if attempt > 1 {
|
|
||||||
log.Printf("[agent] provider %q transient failure, retry %d/%d in 2s: %v",
|
|
||||||
fbProvider.Name(), attempt, maxAttempts, llmErr)
|
|
||||||
select {
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
case <-a.ctx.Done():
|
|
||||||
llmErr = a.ctx.Err()
|
|
||||||
}
|
|
||||||
if llmErr == nil || errors.Is(llmErr, context.Canceled) || errors.Is(llmErr, context.DeadlineExceeded) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fCtx, fCancel := context.WithCancel(a.ctx)
|
|
||||||
a.llmMu.Lock()
|
|
||||||
a.cancelLLM = fCancel
|
|
||||||
a.llmMu.Unlock()
|
|
||||||
|
|
||||||
resp, llmErr = chatStreamWithFallback(fCtx, fbProvider, req, a)
|
|
||||||
|
|
||||||
a.llmMu.Lock()
|
|
||||||
a.cancelLLM = nil
|
|
||||||
a.llmMu.Unlock()
|
|
||||||
fCancel()
|
|
||||||
|
|
||||||
if llmErr == nil {
|
|
||||||
a.providerManager.ResetAvailability(fbProvider.Name())
|
|
||||||
if fbProvider != a.provider {
|
|
||||||
a.provider = fbProvider
|
|
||||||
log.Printf("[agent] switched active provider to %q after fallback",
|
|
||||||
fbProvider.Name())
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户中断:立即终止,不重试也不换 provider
|
|
||||||
if errors.Is(llmErr, context.Canceled) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
// 凭证错误:重试无意义,跳出重试循环进入 provider 标记/切换
|
|
||||||
var pe *agentAPI.ProviderError
|
|
||||||
if errors.As(llmErr, &pe) && (pe.StatusCode == 401 || pe.StatusCode == 403) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
// 其余错误(含 5xx/429/网络):还有重试机会则继续,否则跳出
|
|
||||||
}
|
|
||||||
|
|
||||||
if llmErr == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if errors.Is(llmErr, context.Canceled) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
var pe *agentAPI.ProviderError
|
|
||||||
if errors.As(llmErr, &pe) && (pe.StatusCode == 401 || pe.StatusCode == 403) {
|
|
||||||
a.providerManager.ReportStatus(fbProvider.Name(), pe.StatusCode)
|
|
||||||
log.Printf("[agent] provider %q marked unavailable (HTTP %d)", fbProvider.Name(), pe.StatusCode)
|
|
||||||
} else {
|
|
||||||
a.providerManager.MarkUnavailable(fbProvider.Name())
|
|
||||||
}
|
|
||||||
log.Printf("[agent] provider %q failed: %v", fbProvider.Name(), llmErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if llmErr != nil {
|
|
||||||
if errors.Is(llmErr, context.Canceled) && a.ctx.Err() == nil {
|
|
||||||
if a.currentOutputChannel == "_consolidation_" {
|
|
||||||
return "", toolsUsed, toolResults, fmt.Errorf("interrupted by user input")
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return "", toolsUsed, toolResults, fmt.Errorf("all %d providers failed, last error: %w",
|
|
||||||
len(providers), llmErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
stageCtx.LLMText = resp.Content
|
|
||||||
stageCtx.ReasoningContent = resp.ReasoningContent
|
|
||||||
stageCtx.TokenUsage = map[string]int{
|
|
||||||
"prompt_tokens": resp.TokenUsage.Prompt,
|
|
||||||
"completion_tokens": resp.TokenUsage.Completion,
|
|
||||||
"total_tokens": resp.TokenUsage.Total,
|
|
||||||
}
|
|
||||||
stageCtx.ToolCalls = convertToolCalls(resp.ToolCalls)
|
|
||||||
for i := range stageCtx.ToolCalls {
|
|
||||||
if stageCtx.ToolCalls[i].Plugin == "" {
|
|
||||||
stageCtx.ToolCalls[i].Plugin = a.resolveToolPlugin(stageCtx.ToolCalls[i].Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if a.runStage(sdk.StagePostAction, stageCtx) {
|
|
||||||
return *stageCtx.Response, toolsUsed, toolResults, nil
|
|
||||||
}
|
|
||||||
resp.Content = stageCtx.LLMText
|
|
||||||
resp.ToolCalls = convertBackToolCalls(stageCtx.ToolCalls)
|
|
||||||
|
|
||||||
chainPayload := map[string]interface{}{
|
|
||||||
"content": resp.Content,
|
|
||||||
"reasoning": resp.ReasoningContent,
|
|
||||||
"tool_calls": resp.ToolCalls,
|
|
||||||
"phase": "intermediate",
|
|
||||||
"turn": turn,
|
|
||||||
}
|
|
||||||
if resp.TokenUsage.Total > 0 {
|
|
||||||
chainPayload["usage"] = map[string]int{
|
|
||||||
"prompt": resp.TokenUsage.Prompt,
|
|
||||||
"completion": resp.TokenUsage.Completion,
|
|
||||||
"total": resp.TokenUsage.Total,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
a.publishEvent(events.EventAgentLLMChain, chainPayload)
|
|
||||||
|
|
||||||
if resp.ReasoningContent != "" {
|
|
||||||
a.publishEvent(events.EventReasoning, map[string]interface{}{
|
|
||||||
"content": resp.ReasoningContent,
|
|
||||||
"channel": a.currentOutputChannel,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(resp.ToolCalls) == 0 {
|
|
||||||
return resp.Content, toolsUsed, toolResults, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 本批是否全部是输出通道发送(=模型刚交付了给用户的回复)。
|
|
||||||
// 必须在执行前判定:执行过程中的中断/拒绝分支会 continue/break,
|
|
||||||
// 放在循环里统计会漏。
|
|
||||||
replyOnly := true
|
|
||||||
for _, tc := range resp.ToolCalls {
|
|
||||||
if !isOutputDeliveryTool(tc.Name) {
|
|
||||||
replyOnly = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
contentOnce := true
|
|
||||||
for _, tc := range resp.ToolCalls {
|
|
||||||
if len(a.interceptCh) > 0 {
|
|
||||||
for _, interrupt := range a.drainInterrupts() {
|
|
||||||
msgs = append(msgs, agentAPI.Message{Role: "system", Content: "[中断消息] " + interrupt})
|
|
||||||
}
|
|
||||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
|
||||||
"tool": tc.Name,
|
|
||||||
"plugin": a.resolveToolPlugin(tc.Name),
|
|
||||||
"args": tc.Arguments,
|
|
||||||
"status": "interrupted",
|
|
||||||
"reason": "user interrupt before execution",
|
|
||||||
"channel": a.currentOutputChannel,
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
toolsUsed = append(toolsUsed, tc.Name)
|
|
||||||
pluginName := a.resolveToolPlugin(tc.Name)
|
|
||||||
log.Printf("[agent] executing tool: %s (plugin=%s, id=%s)", tc.Name, pluginName, tc.ID)
|
|
||||||
if tc.RawArguments != "" {
|
|
||||||
log.Printf("[agent] tool %s raw_arguments: %s", tc.Name, truncateStr(tc.RawArguments, 300))
|
|
||||||
}
|
|
||||||
|
|
||||||
sdkTC := sdk.ToolCall{ID: tc.ID, Name: tc.Name, Plugin: pluginName, Arguments: tc.Arguments}
|
|
||||||
stageCtx.ToolCalls = []sdk.ToolCall{sdkTC}
|
|
||||||
stageCtx.ToolResults = nil
|
|
||||||
if a.runStage(sdk.StageBeforeToolcall, stageCtx) {
|
|
||||||
result := fmt.Sprintf("工具 %s 已被插件拒绝", tc.Name)
|
|
||||||
msgs = append(msgs, agentAPI.Message{Role: "assistant", ToolCalls: []agentAPI.ToolCall{tc}})
|
|
||||||
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
|
|
||||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
|
||||||
"tool": tc.Name,
|
|
||||||
"plugin": pluginName,
|
|
||||||
"args": tc.Arguments,
|
|
||||||
"result": result,
|
|
||||||
"status": "denied",
|
|
||||||
"channel": a.currentOutputChannel,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
tc.Arguments = stageCtx.ToolCalls[0].Arguments
|
|
||||||
|
|
||||||
if pluginName != "" && !a.pluginHealth.isHealthy(pluginName) {
|
|
||||||
result := fmt.Sprintf("插件 %s 处于崩溃状态,已跳过执行,等待自动恢复重载", pluginName)
|
|
||||||
log.Printf("[agent] skip tool %s: plugin %s unhealthy", tc.Name, pluginName)
|
|
||||||
msgs = append(msgs, agentAPI.Message{Role: "assistant", ToolCalls: []agentAPI.ToolCall{tc}})
|
|
||||||
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
result := a.executeToolCall(tc)
|
|
||||||
toolResults = append(toolResults, ToolResultItem{Name: tc.Name, Output: result})
|
|
||||||
log.Printf("[agent] tool %s result: %s", tc.Name, truncateStr(result, 100))
|
|
||||||
|
|
||||||
stageCtx.ToolResults = []sdk.ToolResult{{CallID: tc.ID, Name: tc.Name, Plugin: pluginName, Success: true, Result: result}}
|
|
||||||
a.runStage(sdk.StageAfterToolcall, stageCtx)
|
|
||||||
if len(stageCtx.ToolResults) > 0 {
|
|
||||||
if r, ok := stageCtx.ToolResults[0].Result.(string); ok {
|
|
||||||
result = r
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ContextPolicy: prune 工具调用后执行上下文裁剪(§13.8)
|
|
||||||
if def := a.stageHost.ToolDef(tc.Name); def != nil && def.ContextPolicy == "prune" {
|
|
||||||
if a.context != nil {
|
|
||||||
topK := a.maxContextSize - 1
|
|
||||||
if topK < 1 {
|
|
||||||
topK = 1
|
|
||||||
}
|
|
||||||
// 查询向量取**清洗后**的有效内容,否则噪声(ANSI/base64/JSON
|
|
||||||
// 包装)会把相关性打分带偏,裁掉本该保留的事件。
|
|
||||||
a.context.Prune(a.toolOutputForQuery(tc.Name, result), topK, a.docStore)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
msgContent := ""
|
|
||||||
if contentOnce {
|
|
||||||
msgContent = resp.Content
|
|
||||||
contentOnce = false
|
|
||||||
}
|
|
||||||
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: msgContent, ReasoningContent: resp.ReasoningContent, ToolCalls: []agentAPI.ToolCall{tc}})
|
|
||||||
|
|
||||||
// 多模态工具结果:插件通过 SDK.SetToolBlocks 注入 image_url/audio_url block。
|
|
||||||
//
|
|
||||||
// 媒体不挂在 tool message 上,而是另起一条紧随其后的 user message——
|
|
||||||
// 这也是插件文案一直在说的「注入后续对话」。
|
|
||||||
// 为何不能挂 tool message:同一张图、同一模型、三轮实测——
|
|
||||||
// 图在 user message → 3/3 读到
|
|
||||||
// 图在 tool message → 0/3(模型答「没能读到这张图」)
|
|
||||||
// tool 纯文本 + 后接 user → 3/3 读到
|
|
||||||
// tool message 那轮 prompt_tokens 反而更高(7967 vs 7089),base64 确实
|
|
||||||
// 进了上游,但 role=tool 上的多模态 content 数组不被当作可视内容。
|
|
||||||
//
|
|
||||||
// 主模型不支持该模态时更不能直接塞:网关会把 image_url 静默剥离后仍
|
|
||||||
// 返回 200,模型回答「我没有看到图片」而内核以为注入成功。改走回退链。
|
|
||||||
toolMsg := agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result}
|
|
||||||
var mediaMsg *agentAPI.Message
|
|
||||||
if rawBlocks := a.io.ConsumeToolBlocks(); len(rawBlocks) > 0 {
|
|
||||||
var blocks []agentAPI.ContentBlock
|
|
||||||
for _, b := range rawBlocks {
|
|
||||||
if cb, ok := b.(pubsdk.ContentBlock); ok {
|
|
||||||
// 跨包类型拷贝(pubsdk.ContentBlock → agentAPI.ContentBlock)
|
|
||||||
block := agentAPI.ContentBlock{Type: cb.Type, Text: cb.Text}
|
|
||||||
if cb.ImageURL != nil {
|
|
||||||
block.ImageURL = &agentAPI.ImageURL{URL: cb.ImageURL.URL, Detail: cb.ImageURL.Detail}
|
|
||||||
}
|
|
||||||
if cb.AudioURL != nil {
|
|
||||||
block.AudioURL = &agentAPI.AudioURL{URL: cb.AudioURL.URL}
|
|
||||||
}
|
|
||||||
blocks = append(blocks, block)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(blocks) > 0 {
|
|
||||||
// 先落进 CAS:无论下面走直视还是回退转写,媒体本体都该进记忆。
|
|
||||||
// 不存的后果是 ToolResultItem.Output 只剩那句
|
|
||||||
// "[已将图片注入后续对话] /tmp/x.png",文件一删线索就断了。
|
|
||||||
a.stageMediaDigests(a.captureBlockMedia(blocks, tc.Name)...)
|
|
||||||
|
|
||||||
if native, fallbackText := a.prepareToolBlocks(blocks); len(native) > 0 {
|
|
||||||
// 能直视:另起一条 user message 承载媒体,并补一句来源说明,
|
|
||||||
// 否则模型会把它当成用户新发的图而不是工具拉回来的。
|
|
||||||
mediaBlocks := append([]agentAPI.ContentBlock{{
|
|
||||||
Type: "text",
|
|
||||||
Text: fmt.Sprintf("[以下是 %s 注入的媒体内容]", tc.Name),
|
|
||||||
}}, native...)
|
|
||||||
mediaMsg = &agentAPI.Message{Role: "user", Blocks: mediaBlocks}
|
|
||||||
} else if fallbackText != "" {
|
|
||||||
// 回退链已把媒体转写成文字:并进 tool message 的纯文本 content,
|
|
||||||
// 不再另起消息(文字在 tool message 里本来就能被读到)。
|
|
||||||
toolMsg.Content = result + "\n\n" + fallbackText
|
|
||||||
result = toolMsg.Content
|
|
||||||
if len(toolResults) > 0 {
|
|
||||||
toolResults[len(toolResults)-1].Output = result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
msgs = append(msgs, toolMsg)
|
|
||||||
if mediaMsg != nil {
|
|
||||||
// 必须紧跟在 toolMsg 之后:中间插入其他消息会让 tool_call_id 配对断开。
|
|
||||||
msgs = append(msgs, *mediaMsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
|
||||||
"tool": tc.Name,
|
|
||||||
"plugin": pluginName,
|
|
||||||
"args": tc.Arguments,
|
|
||||||
"result": result,
|
|
||||||
"status": "ok",
|
|
||||||
"channel": a.currentOutputChannel,
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(a.interceptCh) > 0 {
|
|
||||||
for _, interrupt := range a.drainInterrupts() {
|
|
||||||
msgs = append(msgs, agentAPI.Message{Role: "system", Content: "[中断消息] " + interrupt})
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 供下一轮顶部选择补位文案。
|
|
||||||
lastBatchReplyOnly = replyOnly
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// chatStreamWithFallback 优先流式调用 provider,失败时回退非流式 Chat()。
|
// chatStreamWithFallback 优先流式调用 provider,失败时回退非流式 Chat()。
|
||||||
//
|
//
|
||||||
// 流式路径:ChatStream 拿到 chunk channel,逐块累积 content/reasoning_content,
|
// 流式路径:ChatStream 拿到 chunk channel,逐块累积 content/reasoning_content,
|
||||||
@ -525,14 +109,14 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
|||||||
//
|
//
|
||||||
// 超时收益:首包 ~1-3s 到达即建立活性,后续只要 token 在流动就不会触发
|
// 超时收益:首包 ~1-3s 到达即建立活性,后续只要 token 在流动就不会触发
|
||||||
// 空闲超时;总生成时长不再受限於 180s 整体超时。
|
// 空闲超时;总生成时长不再受限於 180s 整体超时。
|
||||||
func chatStreamWithFallback(ctx context.Context, p agentAPI.Provider, req *agentAPI.CompletionRequest, a *Agent) (*agentAPI.CompletionResponse, error) {
|
func chatStreamWithFallback(ctx context.Context, p agentAPI.Provider, req *agentAPI.CompletionRequest, a *Agent, channel string) (*agentAPI.CompletionResponse, error) {
|
||||||
ch, err := p.ChatStream(ctx, req)
|
ch, err := p.ChatStream(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[agent] stream connect failed (%v), falling back to non-stream chat", err)
|
log.Printf("[agent] stream connect failed (%v), falling back to non-stream chat", err)
|
||||||
return p.Chat(ctx, req)
|
return p.Chat(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, accErr := accumulateStream(ctx, ch, a)
|
resp, accErr := accumulateStream(ctx, ch, a, channel)
|
||||||
|
|
||||||
// 中断/超时取消必须保持取消语义传给调用方(与原 Chat() 行为一致:
|
// 中断/超时取消必须保持取消语义传给调用方(与原 Chat() 行为一致:
|
||||||
// 被 cancel 时丢弃已收内容返回 err),让 process() 的 continue 分支
|
// 被 cancel 时丢弃已收内容返回 err),让 process() 的 continue 分支
|
||||||
@ -543,7 +127,7 @@ func chatStreamWithFallback(ctx context.Context, p agentAPI.Provider, req *agent
|
|||||||
if a != nil {
|
if a != nil {
|
||||||
a.publishEvent(events.EventContentDelta, map[string]interface{}{
|
a.publishEvent(events.EventContentDelta, map[string]interface{}{
|
||||||
"content": "",
|
"content": "",
|
||||||
"channel": a.currentOutputChannel,
|
"channel": channel,
|
||||||
"reset": true,
|
"reset": true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -573,7 +157,7 @@ type toolCallAcc struct {
|
|||||||
|
|
||||||
// accumulateStream 消费 chunk channel,累积为完整 CompletionResponse,
|
// accumulateStream 消费 chunk channel,累积为完整 CompletionResponse,
|
||||||
// 同时发布增量事件。返回的 response 与非流式 Chat() 的返回等价。
|
// 同时发布增量事件。返回的 response 与非流式 Chat() 的返回等价。
|
||||||
func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Agent) (*agentAPI.CompletionResponse, error) {
|
func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Agent, channel string) (*agentAPI.CompletionResponse, error) {
|
||||||
resp := &agentAPI.CompletionResponse{
|
resp := &agentAPI.CompletionResponse{
|
||||||
ToolCalls: make([]agentAPI.ToolCall, 0),
|
ToolCalls: make([]agentAPI.ToolCall, 0),
|
||||||
}
|
}
|
||||||
@ -625,7 +209,7 @@ func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Ag
|
|||||||
if a != nil {
|
if a != nil {
|
||||||
a.publishEvent(events.EventReasoningDelta, map[string]interface{}{
|
a.publishEvent(events.EventReasoningDelta, map[string]interface{}{
|
||||||
"content": ck.ReasoningContent,
|
"content": ck.ReasoningContent,
|
||||||
"channel": a.currentOutputChannel,
|
"channel": channel,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -634,7 +218,7 @@ func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Ag
|
|||||||
if a != nil {
|
if a != nil {
|
||||||
a.publishEvent(events.EventContentDelta, map[string]interface{}{
|
a.publishEvent(events.EventContentDelta, map[string]interface{}{
|
||||||
"content": ck.Content,
|
"content": ck.Content,
|
||||||
"channel": a.currentOutputChannel,
|
"channel": channel,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -747,11 +331,13 @@ func (a *Agent) formatMergedTimeline(maxTokens int) string {
|
|||||||
include := 0
|
include := 0
|
||||||
for i := len(events) - 1; i >= 0; i-- {
|
for i := len(events) - 1; i >= 0; i-- {
|
||||||
e := events[i]
|
e := events[i]
|
||||||
est := len(e.Source) + len(e.Input) + 40
|
// ❗单位必须与 EstimateTokens 一致(rune×2)。这里曾用 `len()`(**字节**)再 ×2:
|
||||||
|
// CJK 一字 3 字节 ⇒ 中文事件被高估 3 倍,窗口还有余量也会提前 break,
|
||||||
|
// 把更早的事件整段丢掉(实测:2384 字的中文事件被估成 14398 token > 8192)。
|
||||||
|
estTokens := EstimateTokens(e.Source) + EstimateTokens(e.Input) + 40
|
||||||
if e.Response != "" {
|
if e.Response != "" {
|
||||||
est += 120
|
estTokens += 120
|
||||||
}
|
}
|
||||||
estTokens := est * 2
|
|
||||||
if remaining-estTokens < 0 && include > 0 {
|
if remaining-estTokens < 0 && include > 0 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
515
internal/agent/core/resident.go
Normal file
515
internal/agent/core/resident.go
Normal file
@ -0,0 +1,515 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// 驻留式子 agent 的**生命周期与控制面**(设计 docs/zh/resident-subagent-design.md §7/§9/§10)。
|
||||||
|
//
|
||||||
|
// 父 ──创建/发送消息/查看/压缩/回收/销毁──► 驻留子
|
||||||
|
// 子 ──主动消息(L3) / contextfull(L4)──► 父
|
||||||
|
//
|
||||||
|
// 层级关系:
|
||||||
|
// - 父持**登记表**(residents),它是查看·发送·压缩·回收·销毁的寻址依据;
|
||||||
|
// - 父 `Stop()` ⇒ 销毁全部子(**不留孤儿**);
|
||||||
|
// - 子的 `KernelSource` = 父;父则把子的 contextfull 当**内核级事件**(raiseKernelInterrupt)上报给自己;
|
||||||
|
// - 子持有自己的 **inputch 处理表**(父 pull,不打断子)。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InputchRecord 是 inputch 处理表的一条记录(子持有,父 pull)。
|
||||||
|
type InputchRecord struct {
|
||||||
|
InputCh string `json:"inputch"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Proactive bool `json:"proactive"` // true = 子主动写入;false = 系统自动写
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResidentOptions 是创建一个驻留子的参数(父的"创建"动作)。
|
||||||
|
type ResidentOptions struct {
|
||||||
|
// ID 是子 agent 的 id(同时是登记表的键、跨 agent 寻址的依据)。
|
||||||
|
ID string
|
||||||
|
// TaskPrompt 是在固定提示词之上注入的**任务提示词**。
|
||||||
|
TaskPrompt string
|
||||||
|
// InputChs 是**划入**给这个子的 inputch(单位 = inputch;可来自同一插件的多个)。
|
||||||
|
InputChs []string
|
||||||
|
// AllowedOutputs 是授权给它的输出通道集合(nil/空 = 完整授权)。
|
||||||
|
AllowedOutputs []string
|
||||||
|
// Capacity 是划入 inputch 的队列容量(0 = 内核默认)。
|
||||||
|
Capacity int
|
||||||
|
// TempPath 是它 temp 图记忆的存储路径(必填;与子同生共死)。
|
||||||
|
TempPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResidentInfo 是父对某个驻留子的可查询状态(登记表条目 + 状态面摘要)。
|
||||||
|
type ResidentInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
State string `json:"state"`
|
||||||
|
InputChs []string `json:"inputchs"`
|
||||||
|
AllowedOutputs []string `json:"allowed_outputs"`
|
||||||
|
Rounds int `json:"rounds"`
|
||||||
|
ContextFull bool `json:"context_full"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
TableSize int `json:"table_size"`
|
||||||
|
Table []InputchRecord `json:"table,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type residentChild struct {
|
||||||
|
id string
|
||||||
|
agent *Agent
|
||||||
|
light *memory.LightMemory
|
||||||
|
mainRO *memory.GraphDB
|
||||||
|
tempPath string
|
||||||
|
dir string
|
||||||
|
inputChs []string
|
||||||
|
allowed []string
|
||||||
|
createdAt time.Time
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
state string // running | contextfull | stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
// mainGraph 返回父自己的完整图记忆库(驻留子的受限句柄由它派生)。
|
||||||
|
func (a *Agent) mainGraph() *memory.GraphDB { return a.memory }
|
||||||
|
|
||||||
|
// SpawnResident 创建一个驻留子(父的"创建"动作)。
|
||||||
|
func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
|
||||||
|
if strings.TrimSpace(opts.ID) == "" {
|
||||||
|
return ResidentInfo{}, fmt.Errorf("驻留子必须有 id")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(opts.TempPath) == "" {
|
||||||
|
return ResidentInfo{}, fmt.Errorf("驻留子必须给出 temp 图记忆路径")
|
||||||
|
}
|
||||||
|
main := a.mainGraph()
|
||||||
|
if main == nil {
|
||||||
|
return ResidentInfo{}, fmt.Errorf("父没有图记忆,无法为驻留子提供主库只读视图")
|
||||||
|
}
|
||||||
|
|
||||||
|
a.residentMu.Lock()
|
||||||
|
if a.residents == nil {
|
||||||
|
a.residents = map[string]*residentChild{}
|
||||||
|
}
|
||||||
|
if _, dup := a.residents[opts.ID]; dup {
|
||||||
|
a.residentMu.Unlock()
|
||||||
|
return ResidentInfo{}, fmt.Errorf("驻留子 %s 已存在", opts.ID)
|
||||||
|
}
|
||||||
|
a.residentMu.Unlock()
|
||||||
|
|
||||||
|
// ① 轻量内核的记忆装配:主库**受限句柄**(结构上写不进)+ 自己的 temp 实例。
|
||||||
|
// temp 目录由内核创建(调用方只给路径)——与子同生共死,销毁时整目录丢弃。
|
||||||
|
if dir := filepath.Dir(opts.TempPath); dir != "" && dir != "." {
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return ResidentInfo{}, fmt.Errorf("创建 temp 目录: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mainRO, err := memory.OpenGraphDBReadOnly(main.Path())
|
||||||
|
if err != nil {
|
||||||
|
return ResidentInfo{}, fmt.Errorf("打开主库受限句柄: %w", err)
|
||||||
|
}
|
||||||
|
light, err := memory.NewLightMemory(mainRO, opts.TempPath, true)
|
||||||
|
if err != nil {
|
||||||
|
_ = mainRO.Close()
|
||||||
|
return ResidentInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 划入 inputch(登记表里记归属;一个插件的多个 inputch 可分别划给不同子)。
|
||||||
|
if reg := a.io.ChannelRegistry(); reg != nil {
|
||||||
|
for _, ch := range opts.InputChs {
|
||||||
|
if err := reg.Assign(ch, opts.ID, opts.Capacity); err != nil {
|
||||||
|
_ = light.Close()
|
||||||
|
_ = mainRO.Close()
|
||||||
|
return ResidentInfo{}, fmt.Errorf("划入 inputch %s: %w", ch, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 子的 io:**独立**的 IOManager(自己的输入通道入口),但共享通道登记表。
|
||||||
|
childIO := agentIO.NewIOManager()
|
||||||
|
if reg := a.io.ChannelRegistry(); reg != nil {
|
||||||
|
childIO.SetChannelRegistry(reg)
|
||||||
|
}
|
||||||
|
|
||||||
|
parentID := string(a.id)
|
||||||
|
child := New(AgentConfig{
|
||||||
|
ID: types.AgentID(opts.ID),
|
||||||
|
Provider: a.provider,
|
||||||
|
ProviderManager: a.providerManager,
|
||||||
|
IO: childIO,
|
||||||
|
StageHost: a.stageHost,
|
||||||
|
LightMemory: light, // 轻量内核:只有图记忆共同面
|
||||||
|
AllowedOutputs: opts.AllowedOutputs,
|
||||||
|
KernelSource: parentID, // 子的 L4 只属于父
|
||||||
|
ParentID: parentID,
|
||||||
|
TaskPrompt: opts.TaskPrompt,
|
||||||
|
})
|
||||||
|
|
||||||
|
rc := &residentChild{
|
||||||
|
id: opts.ID, agent: child, light: light, mainRO: mainRO,
|
||||||
|
tempPath: opts.TempPath, dir: filepath.Dir(opts.TempPath),
|
||||||
|
inputChs: append([]string(nil), opts.InputChs...),
|
||||||
|
allowed: append([]string(nil), opts.AllowedOutputs...),
|
||||||
|
createdAt: time.Now(), state: "running",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ④ 子 → 父的主动消息(**L3 中断**,带子标识):投进父的 inputch。
|
||||||
|
parentInCh := a.residentInboundChannel(opts.ID)
|
||||||
|
child.notifyParent = func(text string) {
|
||||||
|
a.io.InjectInterruptTextOpts(opts.ID, parentInCh, text,
|
||||||
|
agentIO.InjectOptions{Priority: "L3"})
|
||||||
|
}
|
||||||
|
// ⑤ 子的 contextfull → 父侧的**内核级事件**(L4,带子标识)。
|
||||||
|
child.onContextFull = func() { a.handleChildContextFull(rc) }
|
||||||
|
|
||||||
|
a.residentMu.Lock()
|
||||||
|
a.residents[opts.ID] = rc
|
||||||
|
a.residentMu.Unlock()
|
||||||
|
|
||||||
|
child.Start()
|
||||||
|
|
||||||
|
// ⑥ create 即开工:把任务提示词作为**第一条排队输入**投给子。
|
||||||
|
//
|
||||||
|
// 为什么必须在这里投:TaskPrompt 只进子的系统提示词("你是谁、要做什么"),
|
||||||
|
// 而**不会**让子跑起来 —— 实测现象是子启动后 rounds=0、永远待机
|
||||||
|
// (日志 `[agent] r1 started, waiting for IO interrupts` 之后无事发生)。
|
||||||
|
// 走排队输入(非中断):创建是"安排工作",不是"打断它正在做的事"。
|
||||||
|
if strings.TrimSpace(opts.TaskPrompt) != "" {
|
||||||
|
child.io.InjectInputTo(a.residentParentSource(), parentInCh, "text",
|
||||||
|
map[string]interface{}{"content": opts.TaskPrompt})
|
||||||
|
}
|
||||||
|
return rc.info(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// residentParentSource 是"父给子投递"的输入来源名(子的视角里能看出是谁发的)。
|
||||||
|
func (a *Agent) residentParentSource() string { return "parent/" + string(a.id) }
|
||||||
|
|
||||||
|
// residentInboundChannel 是"父接收某个子的消息"的 inputch 名(登记进登记表可见)。
|
||||||
|
func (a *Agent) residentInboundChannel(childID string) string {
|
||||||
|
ch := "child/" + childID
|
||||||
|
if reg := a.io.ChannelRegistry(); reg != nil {
|
||||||
|
// 归属父自己:它是父的入站 inputch。
|
||||||
|
_ = reg.Register(agentIO.InputChannel{Name: ch, Plugin: "resident", Owner: string(a.id)})
|
||||||
|
}
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// DestroyResident 立刻销毁一个驻留子并从登记表移除(父的"销毁"动作;不收割)。
|
||||||
|
//
|
||||||
|
// 销毁是父**随时**可做的;父退出时由 StopResidents 对全部子执行。
|
||||||
|
func (a *Agent) DestroyResident(id string) error {
|
||||||
|
a.residentMu.Lock()
|
||||||
|
rc, ok := a.residents[id]
|
||||||
|
if ok {
|
||||||
|
delete(a.residents, id)
|
||||||
|
}
|
||||||
|
a.residentMu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("驻留子 %s 不存在", id)
|
||||||
|
}
|
||||||
|
a.teardownResident(rc)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// teardownResident 停内核、放通道、丢 temp(销毁与回收共用)。
|
||||||
|
func (a *Agent) teardownResident(rc *residentChild) {
|
||||||
|
rc.mu.Lock()
|
||||||
|
rc.state = "stopped"
|
||||||
|
rc.mu.Unlock()
|
||||||
|
|
||||||
|
rc.agent.Stop() // 停子的调度器(取消其运行中的任务)
|
||||||
|
if rc.light != nil {
|
||||||
|
_ = rc.light.Close() // 关掉 temp 实例
|
||||||
|
}
|
||||||
|
if rc.mainRO != nil {
|
||||||
|
_ = rc.mainRO.Close()
|
||||||
|
}
|
||||||
|
// temp 与子同生共死:连同目录一起丢弃(回收/销毁都不保留)。
|
||||||
|
if rc.dir != "" && strings.Contains(rc.tempPath, rc.id) {
|
||||||
|
_ = os.RemoveAll(rc.dir)
|
||||||
|
}
|
||||||
|
// 归还划入的 inputch(归属清空 ⇒ 回到"未分配",可再分配)。
|
||||||
|
if reg := a.io.ChannelRegistry(); reg != nil {
|
||||||
|
for _, ch := range rc.inputChs {
|
||||||
|
_ = reg.Assign(ch, "", 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopResidents 销毁全部驻留子(父退出时必须;不留孤儿)。
|
||||||
|
func (a *Agent) StopResidents() int {
|
||||||
|
a.residentMu.Lock()
|
||||||
|
all := make([]*residentChild, 0, len(a.residents))
|
||||||
|
for _, rc := range a.residents {
|
||||||
|
all = append(all, rc)
|
||||||
|
}
|
||||||
|
a.residents = map[string]*residentChild{}
|
||||||
|
a.residentMu.Unlock()
|
||||||
|
for _, rc := range all {
|
||||||
|
a.teardownResident(rc)
|
||||||
|
}
|
||||||
|
return len(all)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Residents 返回登记表的快照(按 id 排序,便于断言与展示稳定)。
|
||||||
|
func (a *Agent) Residents() []ResidentInfo {
|
||||||
|
a.residentMu.Lock()
|
||||||
|
all := make([]*residentChild, 0, len(a.residents))
|
||||||
|
for _, rc := range a.residents {
|
||||||
|
all = append(all, rc)
|
||||||
|
}
|
||||||
|
a.residentMu.Unlock()
|
||||||
|
out := make([]ResidentInfo, 0, len(all))
|
||||||
|
for _, rc := range all {
|
||||||
|
out = append(out, rc.info())
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResidentTable 是父**查看**子的 inputch 处理表(pull,不打断子)。
|
||||||
|
func (a *Agent) ResidentTable(id string) ([]InputchRecord, error) {
|
||||||
|
a.residentMu.Lock()
|
||||||
|
rc, ok := a.residents[id]
|
||||||
|
a.residentMu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("驻留子 %s 不存在", id)
|
||||||
|
}
|
||||||
|
return rc.agent.inputchTableSnapshot(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendToResident 是父的"发送消息":经输出通道寻址到该子的 inputch,
|
||||||
|
// 对子而言是 **L4 中断**(取消当前状态 + 插入新消息)。
|
||||||
|
func (a *Agent) SendToResident(id, text string) error {
|
||||||
|
a.residentMu.Lock()
|
||||||
|
rc, ok := a.residents[id]
|
||||||
|
a.residentMu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("驻留子 %s 不存在", id)
|
||||||
|
}
|
||||||
|
ch := "sub/" + id
|
||||||
|
if len(rc.inputChs) > 0 {
|
||||||
|
ch = rc.inputChs[0]
|
||||||
|
}
|
||||||
|
// 来源 = 父;子的 KernelSource 是父 ⇒ 在子的阶梯上这是合法的 L4。
|
||||||
|
rc.agent.io.InjectInterruptTextOpts(string(a.id), ch, text,
|
||||||
|
agentIO.InjectOptions{Priority: "L4"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompressResident 是父的"压缩"(**保留语义**):压上下文 + **清理处理表**,子继续存在。
|
||||||
|
func (a *Agent) CompressResident(id string) (int, error) {
|
||||||
|
a.residentMu.Lock()
|
||||||
|
rc, ok := a.residents[id]
|
||||||
|
a.residentMu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("驻留子 %s 不存在", id)
|
||||||
|
}
|
||||||
|
dropped := rc.agent.context.TrimKeepRecent(residentKeepRecent)
|
||||||
|
rc.agent.clearInputchTable() // 处理表记的是被压掉那段窗口的逐轮处理 ⇒ 必须清
|
||||||
|
rc.agent.resetContextFull()
|
||||||
|
rc.mu.Lock()
|
||||||
|
rc.state = "running"
|
||||||
|
rc.mu.Unlock()
|
||||||
|
return dropped, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReclaimResident 是父的"回收"(**取消语义**):父读子的 temp → 选记录 → 合入 main
|
||||||
|
// → 丢弃 temp → **取消**该驻留子。
|
||||||
|
//
|
||||||
|
// keep 由父决定"哪些纳入记忆";为 nil 时表示全部合入。
|
||||||
|
func (a *Agent) ReclaimResident(id string, keep func([]InputchRecord, []memory.Triple) []memory.Triple) (ResidentInfo, error) {
|
||||||
|
a.residentMu.Lock()
|
||||||
|
rc, ok := a.residents[id]
|
||||||
|
a.residentMu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return ResidentInfo{}, fmt.Errorf("驻留子 %s 不存在", id)
|
||||||
|
}
|
||||||
|
info := rc.info()
|
||||||
|
|
||||||
|
var promoted int
|
||||||
|
// 收割:读 temp 的全部活跃三元组(比通过图记录选择更直接)。
|
||||||
|
if rc.light != nil && rc.light.Temp() != nil && a.mainGraph() != nil {
|
||||||
|
exported, err := rc.light.Temp().ExportTriples(0)
|
||||||
|
if err != nil {
|
||||||
|
return info, fmt.Errorf("读取子 temp 失败: %w", err)
|
||||||
|
}
|
||||||
|
selected := exported
|
||||||
|
if keep != nil {
|
||||||
|
selected = keep(info.Table, exported)
|
||||||
|
}
|
||||||
|
if len(selected) > 0 {
|
||||||
|
if _, _, err := a.mainGraph().Commit(selected, "reclaim/"+id, 0); err != nil {
|
||||||
|
return info, fmt.Errorf("合入主记忆失败: %w", err)
|
||||||
|
}
|
||||||
|
promoted = len(selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收割完成 ⇒ 取消该驻留子(回收是取消语义,≠ 压缩)。
|
||||||
|
if err := a.DestroyResident(id); err != nil {
|
||||||
|
return info, err
|
||||||
|
}
|
||||||
|
info.State = fmt.Sprintf("reclaimed(promoted=%d)", promoted)
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleChildContextFull 把子的 contextfull 当**内核级事件**上报给父自己:
|
||||||
|
// 父侧 L4 中断(带子标识)—— 只推信号,细节靠"查看"拉状态面。
|
||||||
|
func (a *Agent) handleChildContextFull(rc *residentChild) {
|
||||||
|
rc.mu.Lock()
|
||||||
|
rc.state = "contextfull"
|
||||||
|
rc.mu.Unlock()
|
||||||
|
a.raiseKernelInterrupt("child/"+rc.id, "kernel",
|
||||||
|
fmt.Sprintf("内核事件:驻留子 %s 上下文已满(contextfull)。请【查看】其状态面后决定【压缩】/【回收】/【销毁】。", rc.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkContextFull 判断本 agent 的**积累上下文是否已经装不下窗口**,是则触发一次 contextfull。
|
||||||
|
//
|
||||||
|
// ❗判据为什么不能写成"估算拼好的 f.Msgs":`buildMessages` 拿到的 `budget.ContextTokens`
|
||||||
|
// 本身就是按 `targetUsage = 0.8 × 窗口` 算出来的,时间线**在拼进消息之前就被预算裁过**了。
|
||||||
|
// 于是 `f.Msgs` 的规模结构上封顶在 ~80% 窗口 —— 对 90% 阈值而言是**永远不成立**的判据
|
||||||
|
// (写测试时我用一个比系统提示词还小的窗口才勉强越过线,那等于什么都没测)。
|
||||||
|
//
|
||||||
|
// 正确的事是"**要被裁了**":拿**未裁剪**的积累上下文(`a.context` 的全部事件)估算,
|
||||||
|
// 它超过窗口阈值就说明下一轮必须丢事件 ⇒ 这就是 contextfull。
|
||||||
|
//
|
||||||
|
// 只对**驻留子**生效(只有它们设了 onContextFull)。
|
||||||
|
func (a *Agent) checkContextFull(f *TaskFrame) {
|
||||||
|
if a.onContextFull == nil || a.ctxFullSignaled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
max := 0
|
||||||
|
if a.provider != nil {
|
||||||
|
max = a.provider.MaxContextTokens()
|
||||||
|
}
|
||||||
|
if max <= 0 {
|
||||||
|
max = defaultMaxContextTokens
|
||||||
|
}
|
||||||
|
if a.context == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 未裁剪的积累上下文规模。
|
||||||
|
acc := 0
|
||||||
|
for _, e := range a.context.Recent(0) {
|
||||||
|
acc += EstimateTokens(e.Input) + EstimateTokens(e.Response)
|
||||||
|
}
|
||||||
|
if float64(acc) < float64(max)*contextFullRatio {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.ctxFullSignaled = true
|
||||||
|
a.onContextFull()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) resetContextFull() { a.ctxFullSignaled = false }
|
||||||
|
|
||||||
|
// ---- 子侧:inputch 处理表(子持有,父 pull) ----
|
||||||
|
|
||||||
|
// recordInputchNote 是子**主动写入**本轮 inputch 的处理信息(工具 inputch_note)。
|
||||||
|
func (a *Agent) recordInputchNote(text string) {
|
||||||
|
a.tableMu.Lock()
|
||||||
|
defer a.tableMu.Unlock()
|
||||||
|
a.inputchPending = &InputchRecord{
|
||||||
|
InputCh: a.currentInputch, At: time.Now(), Proactive: true, Text: text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoRecordInputch 是**系统自动写**兜底:本轮未主动写时,把该轮 inputch 的处理信息写入。
|
||||||
|
// 保证每一轮必有记录,父不会看到空洞。
|
||||||
|
func (a *Agent) autoRecordInputch(f *TaskFrame) {
|
||||||
|
if f == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.tableMu.Lock()
|
||||||
|
defer a.tableMu.Unlock()
|
||||||
|
if a.inputchPending != nil {
|
||||||
|
rec := *a.inputchPending
|
||||||
|
a.inputchPending = nil
|
||||||
|
rec.InputCh = firstNonEmpty(rec.InputCh, a.currentInputch)
|
||||||
|
a.inputchTable = append(a.inputchTable, rec)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text := fmt.Sprintf("轮次完成:输入=%s", truncateStr(f.Input, 80))
|
||||||
|
if f.Response != "" {
|
||||||
|
text += ";产出=" + truncateStr(f.Response, 120)
|
||||||
|
}
|
||||||
|
a.inputchTable = append(a.inputchTable, InputchRecord{
|
||||||
|
InputCh: a.currentInputch, At: time.Now(), Proactive: false, Text: text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) inputchTableSnapshot() []InputchRecord {
|
||||||
|
a.tableMu.Lock()
|
||||||
|
defer a.tableMu.Unlock()
|
||||||
|
return append([]InputchRecord(nil), a.inputchTable...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) clearInputchTable() {
|
||||||
|
a.tableMu.Lock()
|
||||||
|
defer a.tableMu.Unlock()
|
||||||
|
a.inputchTable = nil
|
||||||
|
a.inputchPending = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 子侧:主动向父发消息(L3) ----
|
||||||
|
|
||||||
|
func (a *Agent) notifyParentFrom(text string) string {
|
||||||
|
if a.notifyParent == nil {
|
||||||
|
return "本 agent 没有上级,无法发送消息"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
return "消息内容不能为空"
|
||||||
|
}
|
||||||
|
a.notifyParent(text)
|
||||||
|
return "已发送给主 agent"
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(a, b string) string {
|
||||||
|
if a != "" {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// residentKeepRecent 是压缩时保留的最近事件条数。
|
||||||
|
const residentKeepRecent = 20
|
||||||
|
|
||||||
|
// contextFullRatio 是触发 contextfull 的占比:积累上下文超过窗口的这个比例就报。
|
||||||
|
// 取 0.9 而不是 1.0:留一点余量,让父 agent 在"下一次必须丢事件"之前就能决策
|
||||||
|
// (压缩 / 回收 / 销毁),而不是等已经丢了再报。
|
||||||
|
const contextFullRatio = 0.9
|
||||||
|
|
||||||
|
// defaultMaxContextTokens 是 provider 未报告窗口时的兜底(与 ComputeTokenBudget 一致)。
|
||||||
|
const defaultMaxContextTokens = 32768
|
||||||
|
|
||||||
|
func (rc *residentChild) info() ResidentInfo {
|
||||||
|
rc.mu.Lock()
|
||||||
|
state, full := rc.state, rc.state == "contextfull"
|
||||||
|
rc.mu.Unlock()
|
||||||
|
table := rc.agent.inputchTableSnapshot()
|
||||||
|
info := ResidentInfo{
|
||||||
|
ID: rc.id, State: state, InputChs: append([]string(nil), rc.inputChs...),
|
||||||
|
AllowedOutputs: append([]string(nil), rc.allowed...),
|
||||||
|
ContextFull: full, CreatedAt: rc.createdAt, TableSize: len(table),
|
||||||
|
}
|
||||||
|
if len(table) > 0 {
|
||||||
|
info.Table = table
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalResidentInfo 便于工具输出(单工具多视图用 JSON 视图)。
|
||||||
|
func MarshalResidentInfo(info ResidentInfo) string {
|
||||||
|
b, err := json.Marshal(info)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("%+v", info)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
493
internal/agent/core/resident_test.go
Normal file
493
internal/agent/core/resident_test.go
Normal file
@ -0,0 +1,493 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// 驻留子:生命周期(N3)、跨 agent 投递(N4)、inputch 处理表(N5)、contextfull(N6)。
|
||||||
|
//
|
||||||
|
// 设计 docs/zh/resident-subagent-design.md §6/§7/§8/§9/§10。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newRootWith 造一个带完整图记忆的父 agent,并指定它(以及它的驻留子)用的 provider。
|
||||||
|
func newRootWith(t *testing.T, provider agentAPI.Provider) (*Agent, *memory.GraphDB, string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
main, err := memory.NewGraphDB(filepath.Join(dir, "main.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a := New(AgentConfig{
|
||||||
|
ID: "parent",
|
||||||
|
Provider: provider,
|
||||||
|
ProviderManager: agentAPI.NewProviderManager(),
|
||||||
|
IO: agentIO.NewIOManager(),
|
||||||
|
StageHost: NewStageHost(),
|
||||||
|
Memory: main,
|
||||||
|
DataDir: dir,
|
||||||
|
})
|
||||||
|
a.Start() // 父也有自己的调度器:子的消息要真的进它的中断队列并被处理
|
||||||
|
t.Cleanup(func() { a.Stop(); main.Close() })
|
||||||
|
return a, main, dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRootForResidents 是默认构造(正常窗口)。
|
||||||
|
func newRootForResidents(t *testing.T) (*Agent, *memory.GraphDB, string) {
|
||||||
|
t.Helper()
|
||||||
|
return newRootWith(t, &countingProvider{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func spawnTestResident(t *testing.T, parent *Agent, dir, id string, inputChs ...string) ResidentInfo {
|
||||||
|
t.Helper()
|
||||||
|
info, err := parent.SpawnResident(ResidentOptions{
|
||||||
|
ID: id,
|
||||||
|
TaskPrompt: "盯住这个通道,有情况就汇报",
|
||||||
|
InputChs: inputChs,
|
||||||
|
TempPath: filepath.Join(dir, "residents", id, "graph.db"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建驻留子失败: %v", err)
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitFor 轮询直到条件成立(测试里不使用 sleep 猜时序)。
|
||||||
|
func waitFor(t *testing.T, what string, fn func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
waitForWithin(t, what, 5*time.Second, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForWithin(t *testing.T, what string, within time.Duration, fn func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(within)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if fn() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("等待超时:%s", what)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- N3:生命周期 ----
|
||||||
|
|
||||||
|
func TestResident_LifecycleAndNoOrphans(t *testing.T) {
|
||||||
|
parent, _, dir := newRootForResidents(t)
|
||||||
|
|
||||||
|
// 父先注册两个 inputch(模拟插件注册),再把其中一个划给子。
|
||||||
|
reg := parent.io.ChannelRegistry()
|
||||||
|
if err := reg.Register(agentIO.InputChannel{Name: "qq", Plugin: "qq"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := reg.Register(agentIO.InputChannel{Name: "sub/in", Plugin: "sub"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info := spawnTestResident(t, parent, dir, "child-1", "sub/in")
|
||||||
|
if info.State != "running" {
|
||||||
|
t.Fatalf("新建的驻留子状态=%q", info.State)
|
||||||
|
}
|
||||||
|
if list := parent.Residents(); len(list) != 1 || list[0].ID != "child-1" {
|
||||||
|
t.Fatalf("登记表=%+v", list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 划入生效:inputch 的归属变成子。
|
||||||
|
ch, ok := reg.Lookup("sub/in")
|
||||||
|
if !ok || ch.Owner != "child-1" {
|
||||||
|
t.Fatalf("划入未生效:%+v", ch)
|
||||||
|
}
|
||||||
|
// 未划入的仍是未分配。
|
||||||
|
if qq, _ := reg.Lookup("qq"); qq.Owner != "" {
|
||||||
|
t.Fatalf("未划入的 inputch 不该有归属:%+v", qq)
|
||||||
|
}
|
||||||
|
// 父的入站 inputch(接收该子的消息)登记在父名下。
|
||||||
|
if inbound, ok := reg.Lookup("child/child-1"); !ok || inbound.Owner != "parent" {
|
||||||
|
t.Fatalf("父的入站 inputch 未登记:%+v ok=%v", inbound, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 子的轻量内核:有共同面、没有整理面。
|
||||||
|
c1 := parent.residents["child-1"]
|
||||||
|
if c1.agent.memory != nil {
|
||||||
|
t.Fatal("驻留子不该有记忆整理面")
|
||||||
|
}
|
||||||
|
if c1.agent.graphMem() == nil {
|
||||||
|
t.Fatal("驻留子必须有图记忆共同面")
|
||||||
|
}
|
||||||
|
// 子的 L4 只属于父。
|
||||||
|
if !c1.agent.isKernelLevelSource("parent") {
|
||||||
|
t.Fatal("父必须是子的内核级来源(子的 L4 归父独占)")
|
||||||
|
}
|
||||||
|
if c1.agent.isKernelLevelSource("别人") {
|
||||||
|
t.Fatal("非父来源不得成为子的内核级来源")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 销毁:出登记表、归还 inputch、temp 目录丢弃。
|
||||||
|
if err := parent.DestroyResident("child-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(parent.Residents()) != 0 {
|
||||||
|
t.Fatal("销毁后登记表应为空")
|
||||||
|
}
|
||||||
|
if ch, _ := reg.Lookup("sub/in"); ch.Owner != "" {
|
||||||
|
t.Fatalf("销毁后 inputch 应回到未分配:%+v", ch)
|
||||||
|
}
|
||||||
|
if err := parent.DestroyResident("child-1"); err == nil {
|
||||||
|
t.Fatal("重复销毁应报错")
|
||||||
|
}
|
||||||
|
|
||||||
|
// **父退出 ⇒ 全部子销毁、不留孤儿**。
|
||||||
|
spawnTestResident(t, parent, dir, "c-a", "sub/in")
|
||||||
|
spawnTestResident(t, parent, dir, "c-b")
|
||||||
|
if n := parent.StopResidents(); n != 2 {
|
||||||
|
t.Fatalf("StopResidents 销毁 %d 个,期望 2", n)
|
||||||
|
}
|
||||||
|
if len(parent.Residents()) != 0 {
|
||||||
|
t.Fatal("父退出后登记表必须为空")
|
||||||
|
}
|
||||||
|
for _, id := range []string{"c-a", "c-b"} {
|
||||||
|
if _, err := osStat(filepath.Join(dir, "residents", id)); err == nil {
|
||||||
|
t.Fatalf("子 %s 的 temp 目录应被丢弃", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// osStat 只是为了让"目录是否还存在"的断言可读(存在返回 nil 错误)。
|
||||||
|
func osStat(path string) (interface{}, error) {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- N4:跨 agent 投递 ----
|
||||||
|
|
||||||
|
func TestResident_DeliveryBothDirections(t *testing.T) {
|
||||||
|
parent, _, dir := newRootForResidents(t)
|
||||||
|
spawnTestResident(t, parent, dir, "child-1")
|
||||||
|
child := parent.residents["child-1"].agent
|
||||||
|
|
||||||
|
// 父 → 子:发送消息 ⇒ 子在 **L4** 上收到(父是子的内核级来源)。
|
||||||
|
if err := parent.SendToResident("child-1", "先停一下,改做 X"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
waitFor(t, "子收到 L4 中断", func() bool {
|
||||||
|
return child.DumpScheduler().Stats.InterruptsByLevel[LevelCritical] >= 1
|
||||||
|
})
|
||||||
|
|
||||||
|
// 子 → 父:主动消息 ⇒ 父在 **L3** 上收到(不是 L4)。
|
||||||
|
// 注意 L3 的枚举值是 LevelInteractive(LevelMessage 是 L2)。
|
||||||
|
child.notifyParent("我这边发现了点东西")
|
||||||
|
waitFor(t, "父收到子的 L3 消息", func() bool {
|
||||||
|
return parent.DumpScheduler().Stats.InterruptsByLevel[LevelInteractive] >= 1
|
||||||
|
})
|
||||||
|
if got := parent.DumpScheduler().Stats.InterruptsByLevel[LevelCritical]; got != 0 {
|
||||||
|
t.Fatalf("子的主动消息不得以 L4 出现在父的阶梯上(实际 %d 次)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- N5:inputch 处理表 ----
|
||||||
|
|
||||||
|
func TestResident_InputchTableAutoAndProactive(t *testing.T) {
|
||||||
|
parent, _, dir := newRootForResidents(t)
|
||||||
|
spawnTestResident(t, parent, dir, "child-1")
|
||||||
|
child := parent.residents["child-1"].agent
|
||||||
|
|
||||||
|
// 说明:create 会把任务提示词作为**第一条输入**投给子("create 即开工"),
|
||||||
|
// 所以这里先等那一轮写完 —— 表里每多一轮就多一条,正是"每轮必有记录"。
|
||||||
|
waitFor(t, "任务提示词那一轮写入", func() bool {
|
||||||
|
table, err := parent.ResidentTable("child-1")
|
||||||
|
return err == nil && len(table) >= 1
|
||||||
|
})
|
||||||
|
base, err := parent.ResidentTable("child-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
n := len(base)
|
||||||
|
|
||||||
|
// ① 子不主动写 ⇒ 系统自动写(每一轮必有记录)。
|
||||||
|
child.io.InjectInput("sub/in", "text", map[string]interface{}{"content": "干活"})
|
||||||
|
waitFor(t, "自动写处理表", func() bool {
|
||||||
|
table, err := parent.ResidentTable("child-1")
|
||||||
|
return err == nil && len(table) == n+1 && !table[n].Proactive
|
||||||
|
})
|
||||||
|
table, err := parent.ResidentTable("child-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if table[n].InputCh != "sub/in" {
|
||||||
|
t.Fatalf("处理表应记本轮 inputch,实际 %q", table[n].InputCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 子主动写 ⇒ 本轮不再自动写。
|
||||||
|
child.recordInputchNote("本轮我自己记:已完成第一阶段")
|
||||||
|
child.autoRecordInputch(&TaskFrame{Input: "第二轮"})
|
||||||
|
table, err = parent.ResidentTable("child-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(table) != n+2 || !table[n+1].Proactive || !strings.Contains(table[n+1].Text, "第一阶段") {
|
||||||
|
t.Fatalf("主动写优先的语义不成立:%+v", table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- N6:contextfull + 三种处置 ----
|
||||||
|
|
||||||
|
func TestResident_ContextFullAndDispositions(t *testing.T) {
|
||||||
|
parent, main, dir := newRootForResidents(t)
|
||||||
|
|
||||||
|
// ① contextfull:把子的**积累上下文**(a.context,不是拼好的消息)灌到超过窗口 90%。
|
||||||
|
// 注意不能靠"拼好的消息很大"来触发:拼装前时间线已被 token 预算裁到 ~80% 窗口。
|
||||||
|
spawnTestResident(t, parent, dir, "child-1")
|
||||||
|
rc := parent.residents["child-1"]
|
||||||
|
child := rc.agent
|
||||||
|
child.context.Append(ContextEvent{
|
||||||
|
Timestamp: time.Now(), Source: "sub/in",
|
||||||
|
Input: strings.Repeat("上下文填充", 8000), // 40000 字 ≈ 80000 token ≫ 8192×0.9
|
||||||
|
})
|
||||||
|
child.io.InjectInput("sub/in", "text", map[string]interface{}{"content": "继续"})
|
||||||
|
|
||||||
|
// 父在 **L4** 上收到 contextfull(带子标识)—— 只推信号。
|
||||||
|
waitFor(t, "父收到 contextfull 的 L4 中断", func() bool {
|
||||||
|
return parent.DumpScheduler().Stats.InterruptsByLevel[LevelCritical] >= 1
|
||||||
|
})
|
||||||
|
waitFor(t, "父的登记表显示子 contextfull", func() bool {
|
||||||
|
for _, r := range parent.Residents() {
|
||||||
|
if r.ID == "child-1" && r.ContextFull {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
// ② 压缩(保留语义):上下文变短 + 处理表清空 + 子继续存在。
|
||||||
|
child.recordInputchNote("压缩前的记录")
|
||||||
|
if _, err := parent.CompressResident("child-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(parent.Residents()) != 1 {
|
||||||
|
t.Fatal("压缩后子必须继续存在(压缩是保留语义)")
|
||||||
|
}
|
||||||
|
if table, _ := parent.ResidentTable("child-1"); len(table) != 0 {
|
||||||
|
t.Fatalf("压缩必须清理 inputch 处理表,实际 %d 条", len(table))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 回收(取消语义):父选中的 temp 记录合入 main,然后取消该子。
|
||||||
|
if _, _, err := child.graphMem().Commit([]memory.Triple{
|
||||||
|
{Subject: "子的发现", Relation: "指向", Object: "结论"},
|
||||||
|
}, "sess", 1); err != nil {
|
||||||
|
t.Fatalf("子写自己的 temp 应成功: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := parent.ReclaimResident("child-1", reclaimKeepAll); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(parent.Residents()) != 0 {
|
||||||
|
t.Fatal("回收是取消语义:子不该继续存在")
|
||||||
|
}
|
||||||
|
res, err := main.Recall([]string{"子的发现"}, nil, 1, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, e := range res.Entities {
|
||||||
|
if e.Name == "子的发现" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("回收应把选中的 temp 记录合入主记忆")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ④ 销毁:随时可做、立刻移除。
|
||||||
|
spawnTestResident(t, parent, dir, "child-2")
|
||||||
|
if err := parent.DestroyResident("child-2"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(parent.Residents()) != 0 {
|
||||||
|
t.Fatal("销毁后不该还在登记表里")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envInt(key string, def int) int {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- N7:端到端 + 压力 ----
|
||||||
|
|
||||||
|
func TestResident_E2EAndStress(t *testing.T) {
|
||||||
|
parent, _, dir := newRootForResidents(t)
|
||||||
|
reg := parent.io.ChannelRegistry()
|
||||||
|
|
||||||
|
// 压力规模可用环境变量放大(默认 8 子 × 12 轮):
|
||||||
|
// RESIDENT_STRESS_N / RESIDENT_STRESS_ROUNDS
|
||||||
|
nResidents := envInt("RESIDENT_STRESS_N", 8)
|
||||||
|
roundsEach := envInt("RESIDENT_STRESS_ROUNDS", 12)
|
||||||
|
ids := make([]string, 0, nResidents)
|
||||||
|
for i := 0; i < nResidents; i++ {
|
||||||
|
id := "sub-" + string(rune('a'+i))
|
||||||
|
ch := "sub/" + id + "/in"
|
||||||
|
if err := reg.Register(agentIO.InputChannel{Name: ch, Plugin: "sub"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
spawnTestResident(t, parent, dir, id, ch)
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 压力:每个子灌 roundsEach 轮输入;其中一半走父→子的 L4 消息,一半走普通输入。
|
||||||
|
for _, id := range ids {
|
||||||
|
for r := 0; r < roundsEach; r++ {
|
||||||
|
// 内容必须唯一:内核会去重相同输入(去重路径不产生处理表记录)。
|
||||||
|
msg := fmt.Sprintf("%s 第 %d 轮", id, r)
|
||||||
|
if r%2 == 0 {
|
||||||
|
if err := parent.SendToResident(id, msg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parent.residents[id].agent.io.InjectInput("sub/"+id+"/in", "text",
|
||||||
|
map[string]interface{}{"content": msg})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全部子都必须活着,且每一轮都留下处理表记录(自动或主动)。
|
||||||
|
waitForWithin(t, "全部子完成各自轮次", 30*time.Second, func() bool {
|
||||||
|
for _, id := range ids {
|
||||||
|
table, err := parent.ResidentTable(id)
|
||||||
|
if err != nil || len(table) < roundsEach/2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 双向通信在压力下也成立:让每个子都汇报一次(父侧 L3)。
|
||||||
|
for _, id := range ids {
|
||||||
|
parent.residents[id].agent.notifyParent("压力汇报 " + id)
|
||||||
|
}
|
||||||
|
waitForWithin(t, "父收到全部子的汇报", 10*time.Second, func() bool {
|
||||||
|
return parent.DumpScheduler().Stats.InterruptsByLevel[LevelInteractive] >= uint64(nResidents)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 父退出 ⇒ 全部子销毁、登记表清空(不留孤儿)。
|
||||||
|
if n := parent.StopResidents(); n != nResidents {
|
||||||
|
t.Fatalf("父退出应销毁 %d 个子,实际 %d", nResidents, n)
|
||||||
|
}
|
||||||
|
if len(parent.Residents()) != 0 {
|
||||||
|
t.Fatal("父退出后登记表必须为空")
|
||||||
|
}
|
||||||
|
t.Logf("压力通过:%d 个驻留子 × %d 轮(父→子 L4 与普通输入各半)+ 双向汇报",
|
||||||
|
nResidents, roundsEach)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 传统上下文:轻量内核不做动态上下文的裁剪 ----
|
||||||
|
|
||||||
|
// captureProvider 记录模型**实际收到**的消息。
|
||||||
|
//
|
||||||
|
// 为什么不直接看 TaskFrame:`prepareInputTask` 只做前半段(去重/通道/阶段/落上下文),
|
||||||
|
// 消息是在 `runTaskSteps` 的 stepPrepare 里才拼出来的;而且断言"模型看到了什么"
|
||||||
|
// 本来就比断言内核内部字段更接近事实。
|
||||||
|
type captureProvider struct {
|
||||||
|
countingProvider
|
||||||
|
mu sync.Mutex
|
||||||
|
calls int
|
||||||
|
messages []agentAPI.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *captureProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.calls++
|
||||||
|
if req != nil {
|
||||||
|
p.messages = append([]agentAPI.Message(nil), req.Messages...)
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
return &agentAPI.CompletionResponse{Content: "ok"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *captureProvider) chatText() (int, string) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
var b strings.Builder
|
||||||
|
for _, m := range p.messages {
|
||||||
|
b.WriteString(m.Content)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
return p.calls, b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 子 agent 的上下文是**传统上下文**:累积的事件全部交给模型,
|
||||||
|
// 内核**不得**按动态上下文的预算静默丢弃(那是父 agent 的能力)。
|
||||||
|
// 装不下时由 contextfull 上报父决策,而不是自己丢。
|
||||||
|
func TestLightKernel_TraditionalContextNoTrimming(t *testing.T) {
|
||||||
|
provider := &captureProvider{}
|
||||||
|
parent, _, dir := newRootWith(t, provider)
|
||||||
|
spawnTestResident(t, parent, dir, "child-1")
|
||||||
|
child := parent.residents["child-1"].agent
|
||||||
|
|
||||||
|
if !child.isLightKernel() {
|
||||||
|
t.Fatal("驻留子必须被识别为轻量内核")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先等"create 即开工"那一轮(任务提示词)跑完,否则下面抓到的是它的请求,
|
||||||
|
// 而不是我们注入了大段上下文之后的那一轮。
|
||||||
|
waitFor(t, "任务提示词那一轮结束", func() bool {
|
||||||
|
table, err := parent.ResidentTable("child-1")
|
||||||
|
return err == nil && len(table) >= 1
|
||||||
|
})
|
||||||
|
// 前提:动态上下文的份额 < 窗口(否则测不出区别)。
|
||||||
|
b := ComputeTokenBudget(child.provider, child.systemPrompt)
|
||||||
|
if b.MaxContext <= b.ContextTokens {
|
||||||
|
t.Fatalf("前提不成立:窗口(%d) 应大于动态上下文份额(%d)", b.MaxContext, b.ContextTokens)
|
||||||
|
}
|
||||||
|
// 填充量:**超过动态份额、但仍在窗口内**。
|
||||||
|
// ⇒ 完整内核会因预算把最早那条裁掉;轻量内核不该裁(只受窗口硬上限约束)。
|
||||||
|
filler := strings.Repeat("填", b.ContextTokens/2+200)
|
||||||
|
if EstimateTokens(filler) <= b.ContextTokens {
|
||||||
|
t.Fatalf("测试前提不成立:填充(%d token) 应超过动态份额(%d)", EstimateTokens(filler), b.ContextTokens)
|
||||||
|
}
|
||||||
|
child.context.Append(ContextEvent{Timestamp: time.Now(), Source: "sub/in", Input: "最早的事件标记EARLY"})
|
||||||
|
child.context.Append(ContextEvent{Timestamp: time.Now(), Source: "sub/in", Input: filler})
|
||||||
|
child.context.Append(ContextEvent{Timestamp: time.Now(), Source: "sub/in", Input: "最新的事件标记LATE"})
|
||||||
|
|
||||||
|
child.io.InjectInput("sub/in", "text", map[string]interface{}{"content": "本轮输入"})
|
||||||
|
|
||||||
|
// 等**新的一轮**请求(首轮可能已经发过,必须严格等到注入之后那次)。
|
||||||
|
waitFor(t, "子发出新一轮 LLM 请求", func() bool {
|
||||||
|
n, _ := provider.chatText()
|
||||||
|
return n >= 2
|
||||||
|
})
|
||||||
|
_, got := provider.chatText()
|
||||||
|
if !strings.Contains(got, "最早的事件标记EARLY") {
|
||||||
|
t.Fatalf("传统上下文:更早的事件不得被预算裁掉(属于父 agent 的动态上下文能力);实收消息长度=%d", len(got))
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "最新的事件标记LATE") {
|
||||||
|
t.Fatal("最新事件必须在内")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对照:完整内核(根 agent)仍走动态上下文(按预算裁时间线)。
|
||||||
|
func TestFullKernel_StillUsesDynamicContext(t *testing.T) {
|
||||||
|
parent, _, _ := newRootForResidents(t)
|
||||||
|
if parent.isLightKernel() {
|
||||||
|
t.Fatal("根 agent 不是轻量内核")
|
||||||
|
}
|
||||||
|
b := ComputeTokenBudget(parent.provider, "sys")
|
||||||
|
if got := parent.contextTokenBudget(b); got != b.ContextTokens {
|
||||||
|
t.Fatalf("完整内核应使用动态上下文的 ContextTokens(%d),实际 %d", b.ContextTokens, got)
|
||||||
|
}
|
||||||
|
if b.MaxContext <= b.ContextTokens {
|
||||||
|
t.Fatalf("前提不成立:窗口(%d) 应大于动态上下文份额(%d)", b.MaxContext, b.ContextTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
179
internal/agent/core/resident_tools.go
Normal file
179
internal/agent/core/resident_tools.go
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// 驻留子的**工具面**(对照设计 §7 控制面与 §8 处理表)。
|
||||||
|
//
|
||||||
|
// 单工具多动作:父侧一个 `resident_agents`(list/create/send/inspect/compress/reclaim/destroy),
|
||||||
|
// 子侧两个小工具:`notify_parent`(L3 主动汇报)与 `inputch_note`(主动写处理表)。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
func strArg(tc agentAPI.ToolCall, key string) string {
|
||||||
|
s, _ := tc.Arguments[key].(string)
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitArg(s string) []string {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeResidentAgents 是父的驻留子控制面(单工具多动作)。
|
||||||
|
func (a *Agent) executeResidentAgents(tc agentAPI.ToolCall) string {
|
||||||
|
switch action := strArg(tc, "action"); action {
|
||||||
|
case "", "list":
|
||||||
|
list := a.Residents()
|
||||||
|
if len(list) == 0 {
|
||||||
|
return "当前没有驻留子 agent。"
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "驻留子 agent(%d 个):", len(list))
|
||||||
|
for _, r := range list {
|
||||||
|
fmt.Fprintf(&b, "\n - %s [%s] inputch=%v 轮次=%d 处理表=%d",
|
||||||
|
r.ID, r.State, r.InputChs, r.Rounds, r.TableSize)
|
||||||
|
if r.ContextFull {
|
||||||
|
b.WriteString(" ⚠️ contextfull")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
|
||||||
|
case "create":
|
||||||
|
id := strArg(tc, "id")
|
||||||
|
tempPath := strArg(tc, "temp_path")
|
||||||
|
if tempPath == "" {
|
||||||
|
anchor := a.dataDir
|
||||||
|
if anchor == "" {
|
||||||
|
// 兜底:从**主图库路径**推导(<data>/memory/graph.db ⇒ <data>)。
|
||||||
|
// 为什么不静默失败:这条路径只在"配置漏接线"时走到,
|
||||||
|
// 静默报错会让线上表现为"工具能调但永远建不出来"(实测就是这样)。
|
||||||
|
if a.memory != nil && a.memory.Path() != "" {
|
||||||
|
anchor = filepath.Dir(filepath.Dir(a.memory.Path()))
|
||||||
|
log.Printf("[resident] data_dir 未接线,回退到主图库目录: %s", anchor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if anchor == "" {
|
||||||
|
return "创建驻留子需要 data_dir 或显式 temp_path(内核未接线 DataDir)"
|
||||||
|
}
|
||||||
|
tempPath = filepath.Join(anchor, "residents", id, "graph.db")
|
||||||
|
}
|
||||||
|
info, err := a.SpawnResident(ResidentOptions{
|
||||||
|
ID: id,
|
||||||
|
TaskPrompt: strArg(tc, "task_prompt"),
|
||||||
|
InputChs: splitArg(strArg(tc, "input_chs")),
|
||||||
|
AllowedOutputs: splitArg(strArg(tc, "allowed_outputs")),
|
||||||
|
Capacity: intArg(tc, "capacity"),
|
||||||
|
TempPath: tempPath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("创建驻留子失败: %v", err)
|
||||||
|
}
|
||||||
|
return "已创建驻留子: " + MarshalResidentInfo(info)
|
||||||
|
|
||||||
|
case "send":
|
||||||
|
if err := a.SendToResident(strArg(tc, "id"), strArg(tc, "text")); err != nil {
|
||||||
|
return fmt.Sprintf("发送失败: %v", err)
|
||||||
|
}
|
||||||
|
return "已发送(对子而言是 L4 中断)"
|
||||||
|
|
||||||
|
case "inspect":
|
||||||
|
id := strArg(tc, "id")
|
||||||
|
if id == "" {
|
||||||
|
return "inspect 需要 id(或先用 action=list)"
|
||||||
|
}
|
||||||
|
table, err := a.ResidentTable(id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("查看失败: %v", err)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "驻留子 %s 的 inputch 处理表(%d 条):", id, len(table))
|
||||||
|
for _, r := range table {
|
||||||
|
kind := "系统写"
|
||||||
|
if r.Proactive {
|
||||||
|
kind = "主动写"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "\n - [%s][%s] %s", r.InputCh, kind, r.Text)
|
||||||
|
}
|
||||||
|
if len(table) == 0 {
|
||||||
|
b.WriteString("\n (尚无记录)")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
|
||||||
|
case "compress":
|
||||||
|
n, err := a.CompressResident(strArg(tc, "id"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("压缩失败: %v", err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("已压缩子 agent 上下文(丢弃 %d 条旧事件,并发清理其 inputch 处理表);子继续存在", n)
|
||||||
|
|
||||||
|
case "reclaim":
|
||||||
|
info, err := a.ReclaimResident(strArg(tc, "id"), reclaimKeepAll)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("回收失败: %v", err)
|
||||||
|
}
|
||||||
|
return "已回收(temp 中选中的记录已合入主记忆,该驻留子已取消): " + MarshalResidentInfo(info)
|
||||||
|
|
||||||
|
case "destroy":
|
||||||
|
if err := a.DestroyResident(strArg(tc, "id")); err != nil {
|
||||||
|
return fmt.Sprintf("销毁失败: %v", err)
|
||||||
|
}
|
||||||
|
return "已销毁并移除该驻留子"
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("未知 action=%q;可用:list | create | send | inspect | compress | reclaim | destroy", action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reclaimKeepAll 是回收时的默认策略:把子 temp 的活跃记录全部纳入主记忆
|
||||||
|
// ("哪些纳入"由父的模型决定——这里给的是"全要"这一档)。
|
||||||
|
func reclaimKeepAll(_ []InputchRecord, triples []memory.Triple) []memory.Triple { return triples }
|
||||||
|
|
||||||
|
func intArg(tc agentAPI.ToolCall, key string) int {
|
||||||
|
switch v := tc.Arguments[key].(type) {
|
||||||
|
case float64:
|
||||||
|
return int(v)
|
||||||
|
case int:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeNotifyParent 是子的"主动向父发消息"(父侧阶梯 = **L3 中断**)。
|
||||||
|
func (a *Agent) executeNotifyParent(tc agentAPI.ToolCall) string {
|
||||||
|
return a.notifyParentFrom(strArg(tc, "text"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeInputchNote 是子"主动写入本轮 inputch 的处理信息"。
|
||||||
|
// 主动写过 ⇒ 本轮系统不再自动写(见 autoRecordInputch)。
|
||||||
|
func (a *Agent) executeInputchNote(tc agentAPI.ToolCall) string {
|
||||||
|
text := strArg(tc, "text")
|
||||||
|
if text == "" {
|
||||||
|
return "text 不能为空"
|
||||||
|
}
|
||||||
|
a.recordInputchNote(text)
|
||||||
|
return "已记录本轮 inputch 处理信息(本轮系统不会再自动写)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// childInboundChannelHint 是给子看的"父会怎么把消息投给你"的提示(不参与调度)。
|
||||||
|
func childInboundChannelHint(a *Agent) string { return "sub/" + string(a.id) }
|
||||||
|
|
||||||
|
// residentTempDir 返回某个驻留子 temp 存储所在目录(销毁时连同目录丢弃)。
|
||||||
|
func residentTempDir(tempPath string) string { return filepath.Dir(tempPath) }
|
||||||
|
|
||||||
|
var _ = agentIO.InputChannel{} // 保持 agentIO 依赖(工具面未来会用通道登记)
|
||||||
937
internal/agent/core/scheduler.go
Normal file
937
internal/agent/core/scheduler.go
Normal file
@ -0,0 +1,937 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// 输入调度器:四级中断优先级 · 可抢占 · 现场保存/恢复。
|
||||||
|
//
|
||||||
|
// 设计依据 docs/zh/input-scheduler-design.md。
|
||||||
|
//
|
||||||
|
// # 模型(两类别 + 四级)
|
||||||
|
//
|
||||||
|
// 类别由**用哪个注入 API**决定,与通道名无关:
|
||||||
|
// - TaskInterrupt:InjectInterrupt* 注入。带级别 L1..L4,可抢占,
|
||||||
|
// 可被更高级中断打断(被打断的现场压入**中断栈**)。
|
||||||
|
// - TaskQueued:InjectText*/InjectInputSync* 与内核自循环。**无级别**,
|
||||||
|
// 用于“不需及时处理”的场景,可被**任何**中断打断。
|
||||||
|
//
|
||||||
|
// 级别只属于中断:
|
||||||
|
// - L1..L3 由插件在 InjectOptions.Priority 里声明(见 clampPluginLevel);
|
||||||
|
// - L4 给“立即打断”能力:内核自身(raiseKernelInterrupt:panic / selfip)
|
||||||
|
// 与**内核级插件**(编译期内置插件,如 WebUI 终止按钮)可声明;
|
||||||
|
// 外部插件经 proc 桥被夹到 L3,core 里也再判一次来源。
|
||||||
|
//
|
||||||
|
// # 选择顺序
|
||||||
|
//
|
||||||
|
// 1. immediate —— 刚抢占成功的中断(抢占必须立即生效)
|
||||||
|
// 2. 中断队列 L4→L1(同级 FIFO)
|
||||||
|
// 3. 中断栈顶(与 2 的队头比级别,取高者;栈顶无级别时中断必胜)
|
||||||
|
// 4. 排队队列(FIFO)
|
||||||
|
//
|
||||||
|
// # 并发模型(不变量 I2)
|
||||||
|
//
|
||||||
|
// queue/running/栈/stats 只由 schedulerLoop 与调度 goroutine 写;
|
||||||
|
// interruptLoop 只写中断登记与让位信号,**从不碰帧**。外部读取一律经
|
||||||
|
// DumpScheduler() 加锁取快照。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"runtime/debug"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||||
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Level 是**中断**的优先级,由内核预定义四级。
|
||||||
|
//
|
||||||
|
// 语义:它衡量“这项工作有多不能等”,与具体通道名无关。
|
||||||
|
// 插件在中断注入时通过 InjectOptions.Priority 声明 L1..L3;
|
||||||
|
// **L4 由内核独占**(panic、内核事件 selfip),插件声明 L4 会被夹到 L3。
|
||||||
|
//
|
||||||
|
// 排队输入(InjectText* / InjectInputSync*)**没有级别**:它们本就是
|
||||||
|
// “不需及时处理”的那一类,可被任何中断打断(见 TaskClass)。
|
||||||
|
type Level int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// LevelBackground L1:完全可等。例:QQ/微信这类异步消息、批量通知。
|
||||||
|
LevelBackground Level = 1
|
||||||
|
// LevelMessage L2:一般提醒。例:插件希望尽快看到、但不紧急的提示。
|
||||||
|
LevelMessage Level = 2
|
||||||
|
// LevelInteractive L3:需及时处理。例:时钟/定时器到达、终端输出、交互输入。
|
||||||
|
LevelInteractive Level = 3
|
||||||
|
// LevelCritical L4:**内核独占**。panic 中断、内核事件中断(selfip)。
|
||||||
|
// 插件不得声明此级。
|
||||||
|
LevelCritical Level = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultLevel 是未显式声明时的中断级别。
|
||||||
|
//
|
||||||
|
// 取最低级是刻意的:**显式才是特权**,新插件不会默认拿到抢占权。
|
||||||
|
const DefaultLevel = LevelBackground
|
||||||
|
|
||||||
|
// clampPluginLevel 把**非内核级**来源声明的级别夹到 L1..L3。
|
||||||
|
//
|
||||||
|
// L4 是“立即打断”能力(panic / 内核事件 / 内核级插件的终止按钮),
|
||||||
|
// 只给内核与编译期内置插件;外部插件声明 L4 会被夹到 L3。
|
||||||
|
func clampPluginLevel(l Level) Level {
|
||||||
|
if l < LevelBackground {
|
||||||
|
return DefaultLevel
|
||||||
|
}
|
||||||
|
if l > LevelInteractive {
|
||||||
|
return LevelInteractive
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Level) String() string {
|
||||||
|
switch l {
|
||||||
|
case LevelBackground:
|
||||||
|
return "L1-background"
|
||||||
|
case LevelMessage:
|
||||||
|
return "L2-message"
|
||||||
|
case LevelInteractive:
|
||||||
|
return "L3-interactive"
|
||||||
|
case LevelCritical:
|
||||||
|
return "L4-critical"
|
||||||
|
default:
|
||||||
|
return "L?-unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLevel 已删除。
|
||||||
|
//
|
||||||
|
// 为何不保留:优先级是**内核内部属性**,不是配置项。
|
||||||
|
// 曾一度做成 `core.agent.priority.<channel>`(配置中心可见),
|
||||||
|
// 那等于把内核的调度内部属性外化成运维配置,与设计意图相反。
|
||||||
|
//
|
||||||
|
// 现在级别的来源只有两个(见 Task/Level 注释):
|
||||||
|
// - 插件在中断注入时声明(InjectOptions.Priority,L1..L3);
|
||||||
|
// - 内核内部产生 L4(panic / selfip)。
|
||||||
|
|
||||||
|
// TaskClass 是任务的两大类别——**由“用哪个注入 API”决定,与通道名无关**。
|
||||||
|
//
|
||||||
|
// 这是模型的核心区分:
|
||||||
|
// - InjectInterrupt* → TaskInterrupt:带级别,可抢占,可被更高级中断打断(→ 中断栈)
|
||||||
|
// - InjectText* / InjectInputSync* / 内核自循环 → TaskQueued:无级别,
|
||||||
|
// 可被**任何**中断打断(“用于不需要及时处理的场景”)
|
||||||
|
type TaskClass int
|
||||||
|
|
||||||
|
const (
|
||||||
|
TaskQueued TaskClass = iota
|
||||||
|
TaskInterrupt
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c TaskClass) String() string {
|
||||||
|
switch c {
|
||||||
|
case TaskQueued:
|
||||||
|
return "queued"
|
||||||
|
case TaskInterrupt:
|
||||||
|
return "interrupt"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskKind 区分任务来源。
|
||||||
|
type TaskKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TaskKindInput 来自 io.InputChan(外部/插件注入的输入)。
|
||||||
|
TaskKindInput TaskKind = iota
|
||||||
|
// TaskKindSelf 来自 selfInputCh(内核自循环:记忆整理、子任务通知)。
|
||||||
|
TaskKindSelf
|
||||||
|
)
|
||||||
|
|
||||||
|
func (k TaskKind) String() string {
|
||||||
|
switch k {
|
||||||
|
case TaskKindInput:
|
||||||
|
return "input"
|
||||||
|
case TaskKindSelf:
|
||||||
|
return "self"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 是调度器的最小单位。
|
||||||
|
type Task struct {
|
||||||
|
ID uint64
|
||||||
|
Class TaskClass
|
||||||
|
// Level 仅对 TaskInterrupt 有意义;TaskQueued 恒为 0(无级别)。
|
||||||
|
Level Level
|
||||||
|
Kind TaskKind
|
||||||
|
EnqueuedAt time.Time
|
||||||
|
|
||||||
|
Event *agentIO.InputEvent // Kind == TaskKindInput
|
||||||
|
Self selfInputMsg // Kind == TaskKindSelf
|
||||||
|
|
||||||
|
// PreemptCount 是本任务被抢占的次数,用于饥饿防护:
|
||||||
|
// effectiveLevel = min(L4, Level + min(PreemptCount, 2))。
|
||||||
|
PreemptCount int
|
||||||
|
// LastPreemptAt 是上次被抢占的时刻,用于抢占冷却。
|
||||||
|
LastPreemptAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// preemptPromotionCap 是抢占计数能带来的最大提升档数。
|
||||||
|
const preemptPromotionCap = 2
|
||||||
|
|
||||||
|
// preemptCooldown 是“刚被抢占过”的冷却期:期内不再被抢占,
|
||||||
|
// 避免高优先级流把同一任务反复打断到永不完结。
|
||||||
|
const preemptCooldown = 2 * time.Second
|
||||||
|
|
||||||
|
// effectiveLevel 返回任务的**有效**级别。
|
||||||
|
//
|
||||||
|
// 排队输入恒为 0(无级别):任何中断(≥ L1)都大于它——这正好实现
|
||||||
|
// “排队输入可被任何中断打断”。
|
||||||
|
//
|
||||||
|
// 中断则叠加饥饿防护:被抢占越多的中断越“值钱”,逐步追上抢占它的流;
|
||||||
|
// 封顶 L4,因此它永远不会反过来抢占内核紧急中断。
|
||||||
|
func effectiveLevel(t *Task) Level {
|
||||||
|
if t.Class != TaskInterrupt {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
p := t.PreemptCount
|
||||||
|
if p > preemptPromotionCap {
|
||||||
|
p = preemptPromotionCap
|
||||||
|
}
|
||||||
|
l := t.Level + Level(p)
|
||||||
|
if l > LevelCritical {
|
||||||
|
l = LevelCritical
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// canPreempt 是唯一的抢占判据。
|
||||||
|
//
|
||||||
|
// 由于 effectiveLevel(排队)=0,这一个比较同时覆盖两条规则:
|
||||||
|
// - running 是排队任务 → 任何中断(≥L1)都能抢占;
|
||||||
|
// - running 是中断 Li → 只有 Lj > Li 的中断能抢占(严格大于)。
|
||||||
|
func canPreempt(incoming, running *Task) bool {
|
||||||
|
if incoming == nil || running == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if incoming.Class != TaskInterrupt {
|
||||||
|
return false // 排队输入从不抢占
|
||||||
|
}
|
||||||
|
return effectiveLevel(incoming) > effectiveLevel(running)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchedulerStats 是调度器的累计计数(可观测性,设计文档 §11 O2)。
|
||||||
|
//
|
||||||
|
// InterruptsByLevel / PreemptsByLevel 按**中断级别**分桶(下标 1..4):
|
||||||
|
// “各级中断各登记了多少、各真正抢断了多少次”。按级别验收(而不是只看总数)
|
||||||
|
// 是这套调度器的核心判据——总数相同、级别分布不同,行为完全不同。
|
||||||
|
type SchedulerStats struct {
|
||||||
|
Enqueued uint64
|
||||||
|
Executed uint64
|
||||||
|
// Rejected 是因队列满(或深度超限)而未被接纳的次数。
|
||||||
|
Rejected uint64
|
||||||
|
// Suspended / Resumed 是挂起与恢复的次数。
|
||||||
|
// 不变量:系统排空后 Suspended == Resumed(挂起必然被恢复),
|
||||||
|
// 因此两者各自只在**一处**计数(suspend / resumeTask)。
|
||||||
|
Suspended uint64
|
||||||
|
Resumed uint64
|
||||||
|
// InterruptsByLevel[1..4]:各级中断被**登记**的次数(含未抢占成功的)。
|
||||||
|
InterruptsByLevel [5]uint64
|
||||||
|
// PreemptsByLevel[1..4]:各级中断**判定为可抢占并进入 immediate**的次数。
|
||||||
|
// 注意它与 Suspended 不等价:受害者可能在让位信号生效前就自行结束,
|
||||||
|
// 此时抢占者仍然"下一个运行",但没有挂起发生。
|
||||||
|
PreemptsByLevel [5]uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// bumpInterruptLevel 按级别累加(级别必须落在 1..4,否则忽略——
|
||||||
|
// 排队任务没有级别,不该出现在中断计数里)。
|
||||||
|
func (st *SchedulerStats) bumpInterruptLevel(dst *[5]uint64, lv Level) {
|
||||||
|
if lv >= LevelBackground && lv <= LevelCritical {
|
||||||
|
dst[lv]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchedulerSnapshot 是调度器的原子快照。
|
||||||
|
type SchedulerSnapshot struct {
|
||||||
|
Running *Task
|
||||||
|
// Queue 是排队输入队列(无级别,FIFO)。
|
||||||
|
Queue []*Task
|
||||||
|
// InterruptQueues[level] 是四条中断队列(下标 1..4,同级 FIFO)。
|
||||||
|
InterruptQueues [5][]*Task
|
||||||
|
// Immediate 是刚抢占成功、将在下一个安全点立即运行的中断(最多一个)。
|
||||||
|
Immediate *Task
|
||||||
|
// PendingInterrupts = 四条中断队列 + Immediate(对外的待处理中断总数视图)。
|
||||||
|
PendingInterrupts []*Task
|
||||||
|
// SuspendStack:中断栈(含嵌套抢占的多个现场),**栈顶**优先恢复。
|
||||||
|
SuspendStack []*suspendedTask
|
||||||
|
Stats SchedulerStats
|
||||||
|
// MaxInterruptFrames 是中断栈帧数的结构上界(= 中断级数,不是配置项)。
|
||||||
|
MaxInterruptFrames int
|
||||||
|
}
|
||||||
|
|
||||||
|
// schedulerStatus 把快照转成对外的状态 DTO(不暴露帧内容)。
|
||||||
|
func (a *Agent) schedulerStatus() sdk.SchedulerStatus {
|
||||||
|
if a.sched == nil {
|
||||||
|
return sdk.SchedulerStatus{}
|
||||||
|
}
|
||||||
|
snap := a.DumpScheduler()
|
||||||
|
out := sdk.SchedulerStatus{
|
||||||
|
ReadyQueueDepth: len(snap.Queue),
|
||||||
|
PendingInterrupts: len(snap.PendingInterrupts),
|
||||||
|
SuspendStack: len(snap.SuspendStack),
|
||||||
|
MaxSuspendDepth: snap.MaxInterruptFrames,
|
||||||
|
Enqueued: snap.Stats.Enqueued,
|
||||||
|
Executed: snap.Stats.Executed,
|
||||||
|
Rejected: snap.Stats.Rejected,
|
||||||
|
Suspended: snap.Stats.Suspended,
|
||||||
|
Resumed: snap.Stats.Resumed,
|
||||||
|
Preempted: snap.Stats.Suspended,
|
||||||
|
}
|
||||||
|
if snap.Running != nil {
|
||||||
|
out.Running = &sdk.SchedulerTask{
|
||||||
|
ID: snap.Running.ID, Level: int(snap.Running.Level), Kind: snap.Running.Kind.String(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type scheduler struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
queue []*Task
|
||||||
|
running *Task
|
||||||
|
seq uint64
|
||||||
|
stats SchedulerStats
|
||||||
|
maxQueue int
|
||||||
|
|
||||||
|
// interruptQueues[level]:四条**中断队列**(level 1..4),同级 FIFO。
|
||||||
|
// 未能立即抢占的中断(级别不足,或运行任务在临界区)按级别入队,
|
||||||
|
// nextRef 从 L4 到 L1 依次扫描。
|
||||||
|
interruptQueues [5][]*Task
|
||||||
|
// immediate:刚抢占成功的中断。抢占必须**立即生效**,所以它不经队列,
|
||||||
|
// 在下一个安全点直接运行。这也消除了“抢占者与被抢占者同级”的比较问题——
|
||||||
|
// 抢占者根本不需要和栈顶比。
|
||||||
|
immediate *Task
|
||||||
|
// suspendStack:**中断栈**。被抢占后保存现场的任务压栈(LIFO),
|
||||||
|
// 用于“中断被中断”的嵌套场景:只有**栈顶**参与恢复选择,栈内不做优先级重排。
|
||||||
|
suspendStack []*suspendedTask
|
||||||
|
// preemptArmed/preemptLevel:运行任务的“让位信号”。
|
||||||
|
// interruptLoop 只写这两个字段与 pendingInterrupts;帧永远只由调度器读写。
|
||||||
|
preemptArmed bool
|
||||||
|
preemptLevel Level
|
||||||
|
// critical 报告运行任务是否在不可抢占临界区(如记忆整理)。
|
||||||
|
// 由于 interceptLoop 要读它,必须是原子的:帧仍只由调度器读写。
|
||||||
|
critical atomic.Bool
|
||||||
|
// wake 用于把空闲的调度器叫醒:pendingInterrupts 不是 channel,
|
||||||
|
// 没有这个信号时“空闲时到达的中断”会一直等下一次输入(设计 §5.1 ③)。
|
||||||
|
wake chan struct{}
|
||||||
|
// maxInterruptFrames:中断栈帧数的**结构上界**,不是配置项。
|
||||||
|
//
|
||||||
|
// 链条 = 排队(L0) ← I(L1) ← I(L2) ← I(L3) ← I(L4 运行中),
|
||||||
|
// 被挂起 4 帧;L4 之上没有更高级别,链到此为止。超限只可能是内核 bug,
|
||||||
|
// 因此这里只做防御性计数,**不降级、不丢弃帧**。
|
||||||
|
maxInterruptFrames int
|
||||||
|
}
|
||||||
|
|
||||||
|
// suspendedTask 是一个被抢占任务的现场。
|
||||||
|
type suspendedTask struct {
|
||||||
|
Task *Task
|
||||||
|
Frame *TaskFrame
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextSelection 标识 nextRef 从哪个集合取出任务。
|
||||||
|
type nextSelection int
|
||||||
|
|
||||||
|
const (
|
||||||
|
nextNone nextSelection = iota
|
||||||
|
nextReady
|
||||||
|
nextInterrupt
|
||||||
|
nextImmediate
|
||||||
|
nextSuspended
|
||||||
|
)
|
||||||
|
|
||||||
|
func newScheduler(maxQueue int) *scheduler {
|
||||||
|
if maxQueue <= 0 {
|
||||||
|
maxQueue = 256
|
||||||
|
}
|
||||||
|
return &scheduler{
|
||||||
|
maxQueue: maxQueue,
|
||||||
|
maxInterruptFrames: int(LevelCritical), // 结构推论:= 中断级数
|
||||||
|
wake: make(chan struct{}, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// signalWake 非阻塞地唤醒调度器。
|
||||||
|
func (s *scheduler) signalWake() {
|
||||||
|
select {
|
||||||
|
case s.wake <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setCritical 由调度器 goroutine 在任务进入/离开临界区时设置。
|
||||||
|
func (s *scheduler) setCritical(v bool) { s.critical.Store(v) }
|
||||||
|
|
||||||
|
// inCritical 报告运行任务是否在不可抢占临界区。
|
||||||
|
func (s *scheduler) inCritical() bool { return s.critical.Load() }
|
||||||
|
|
||||||
|
// hasRoom 报告排队队列是否还能接收任务。泵入侧据此节流:
|
||||||
|
// 队列满则停止从 channel 取,让背压落回 channel 本身。
|
||||||
|
func (s *scheduler) hasRoom() bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.queue) < s.maxQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
// allocateIDLocked 分配任务 ID 与入队时刻(调用方持锁)。
|
||||||
|
func (s *scheduler) allocateIDLocked(t *Task) {
|
||||||
|
s.seq++
|
||||||
|
t.ID = s.seq
|
||||||
|
if t.EnqueuedAt.IsZero() {
|
||||||
|
t.EnqueuedAt = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueue 把一个**排队输入**入队;队列满返回 false(调用方负责计数)。
|
||||||
|
func (s *scheduler) enqueue(t *Task) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if len(s.queue) >= s.maxQueue {
|
||||||
|
s.stats.Rejected++
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.allocateIDLocked(t)
|
||||||
|
s.stats.Enqueued++
|
||||||
|
s.queue = append(s.queue, t)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// next 取出下一个要执行的任务;队列空返回 nil。
|
||||||
|
//
|
||||||
|
// 保留该签名供已有测试使用;调度器自用 nextRef(需要区分是否携带现场)。
|
||||||
|
func (s *scheduler) next() *Task {
|
||||||
|
t, _, _ := s.nextRef()
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextRef 选出下一个任务。优先顺序:
|
||||||
|
//
|
||||||
|
// 1. immediate —— 刚抢占成功的中断(抢占必须立即生效)
|
||||||
|
// 2. 中断队列 L4→L1(同级 FIFO)
|
||||||
|
// 3. 中断栈顶(与 2 比级别取高者;栈顶是排队任务时视为最低)
|
||||||
|
// 4. 排队队列(FIFO)
|
||||||
|
func (s *scheduler) nextRef() (*Task, *TaskFrame, nextSelection) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.immediate != nil {
|
||||||
|
t := s.immediate
|
||||||
|
s.immediate = nil
|
||||||
|
s.running = t
|
||||||
|
return t, nil, nextImmediate
|
||||||
|
}
|
||||||
|
|
||||||
|
qTask, qLevel := s.highestInterruptLocked()
|
||||||
|
|
||||||
|
// 中断栈:只比**栈顶**(严格 LIFO)。栈内不做优先级重排——
|
||||||
|
// 嵌套抢占天然使栈自底向上级别递增,且“后被打断的先恢复”才是栈语义。
|
||||||
|
if n := len(s.suspendStack); n > 0 {
|
||||||
|
top := s.suspendStack[n-1]
|
||||||
|
// 栈顶 vs 最高级待处理中断:取高者(持平归栈顶,维持 LIFO 与公平)。
|
||||||
|
if qTask == nil || effectiveLevel(top.Task) >= qLevel {
|
||||||
|
// 这里只负责“选出”;Resumed 由 resumeTask 计一次(否则会双计,
|
||||||
|
// 使“排空后 Suspended == Resumed”这条不变量失真)。
|
||||||
|
s.suspendStack = s.suspendStack[:n-1]
|
||||||
|
s.running = top.Task
|
||||||
|
return top.Task, top.Frame, nextSuspended
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if qTask != nil {
|
||||||
|
s.popInterruptLocked(qLevel)
|
||||||
|
s.running = qTask
|
||||||
|
return qTask, nil, nextInterrupt
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(s.queue) > 0 {
|
||||||
|
t := s.queue[0]
|
||||||
|
s.queue = s.queue[1:]
|
||||||
|
s.running = t
|
||||||
|
return t, nil, nextReady
|
||||||
|
}
|
||||||
|
return nil, nil, nextNone
|
||||||
|
}
|
||||||
|
|
||||||
|
// highestInterruptLocked 返回当前最高级非空中断队列的队头及其级别。
|
||||||
|
func (s *scheduler) highestInterruptLocked() (*Task, Level) {
|
||||||
|
for lv := LevelCritical; lv >= LevelBackground; lv-- {
|
||||||
|
if q := s.interruptQueues[lv]; len(q) > 0 {
|
||||||
|
return q[0], lv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// popInterruptLocked 弹出某级别中断队列的队头(调用方已确认非空)。
|
||||||
|
func (s *scheduler) popInterruptLocked(lv Level) {
|
||||||
|
s.interruptQueues[lv] = s.interruptQueues[lv][1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// interruptCountLocked 统计所有待处理中断(含 immediate 槽)。
|
||||||
|
func (s *scheduler) interruptCountLocked() int {
|
||||||
|
n := 0
|
||||||
|
for lv := LevelBackground; lv <= LevelCritical; lv++ {
|
||||||
|
n += len(s.interruptQueues[lv])
|
||||||
|
}
|
||||||
|
if s.immediate != nil {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// setImmediateLocked 登记一个应“立即运行”的抢占者。
|
||||||
|
//
|
||||||
|
// 槽只有一格:若已有抢占者且新的级别更高,旧的降级入队;否则新的入队。
|
||||||
|
func (s *scheduler) setImmediateLocked(t *Task) {
|
||||||
|
if s.immediate != nil && effectiveLevel(t) <= effectiveLevel(s.immediate) {
|
||||||
|
s.enqueueInterruptLocked(t)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.immediate != nil {
|
||||||
|
s.enqueueInterruptLocked(s.immediate)
|
||||||
|
}
|
||||||
|
s.allocateIDLocked(t)
|
||||||
|
s.stats.Enqueued++
|
||||||
|
s.immediate = t
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeTask(list []*Task, target *Task) []*Task {
|
||||||
|
for i, t := range list {
|
||||||
|
if t == target {
|
||||||
|
return append(list[:i], list[i+1:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueueInterruptLocked 把一个未立即抢占的中断按其级别入队(调用方持锁)。
|
||||||
|
//
|
||||||
|
// 有界:满了丢**最老**的一条并计数(中断是提示性输入,宁可丢旧保新)。
|
||||||
|
func (s *scheduler) enqueueInterruptLocked(t *Task) {
|
||||||
|
s.allocateIDLocked(t)
|
||||||
|
if s.interruptCountLocked() >= s.maxQueue {
|
||||||
|
for lv := LevelBackground; lv <= LevelCritical; lv++ {
|
||||||
|
if len(s.interruptQueues[lv]) > 0 {
|
||||||
|
s.popInterruptLocked(lv)
|
||||||
|
s.stats.Rejected++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lv := t.Level
|
||||||
|
if lv < LevelBackground || lv > LevelCritical {
|
||||||
|
lv = DefaultLevel
|
||||||
|
}
|
||||||
|
s.interruptQueues[lv] = append(s.interruptQueues[lv], t)
|
||||||
|
s.stats.Enqueued++
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestPreempt 登记一次中断请求(class=TaskInterrupt)。
|
||||||
|
//
|
||||||
|
// 返回 true 表示“应该尝试取消运行任务正在进行的可取消步骤(LLM 流式)”。
|
||||||
|
//
|
||||||
|
// 判据是 canPreempt(由优先级级别系统一承担),并受抢占冷却约束:
|
||||||
|
// - running 是排队任务 → 任何中断都抢占;
|
||||||
|
// - running 是中断 Li → 仅 Lj > Li 的中断抢占。
|
||||||
|
//
|
||||||
|
// 能抢占时把中断放进 immediate(立即生效);否则按其级别入队,等当前任务
|
||||||
|
// 结束或下一个安全点再处理——无论哪种,中断都不会丢。
|
||||||
|
//
|
||||||
|
// 临界区(如记忆整理)内不 arm、不取消:中断只入队,等临界区结束后的安全点处理,
|
||||||
|
// 这是设计 §4.3 的硬要求——那个位置的“不抢占”不能只是不让位,还必须不取消。
|
||||||
|
// level 必须是**已解析好**的中断级别(含特权判定):
|
||||||
|
// 生产路径只有 interruptLoop,它用 (*Agent).interruptLevel 得出 level;
|
||||||
|
// 内核自身用 requestKernelPreempt(固定 L4)。本函数不再夹取,
|
||||||
|
// 否则内核级插件的 L4 会被无辜削掉。
|
||||||
|
func (s *scheduler) requestPreempt(evt *agentIO.InputEvent, level Level) bool {
|
||||||
|
return s.registerInterrupt(newInterruptTask(evt, level))
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestKernelPreempt 是**内核**中断入口(panic / 内核事件 selfip)。
|
||||||
|
//
|
||||||
|
// 级别固定 L4,且**不夹取**——这是 L4 的唯一来源,插件永远够不到。
|
||||||
|
func (s *scheduler) requestKernelPreempt(evt *agentIO.InputEvent) bool {
|
||||||
|
return s.registerInterrupt(newKernelInterruptTask(evt))
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerInterrupt 是登记中断的公共实现(任务已带好 Class/Level)。
|
||||||
|
func (s *scheduler) registerInterrupt(t *Task) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
running := s.running
|
||||||
|
critical := s.critical.Load()
|
||||||
|
s.stats.bumpInterruptLevel(&s.stats.InterruptsByLevel, t.Level)
|
||||||
|
arm := false
|
||||||
|
if !critical && canPreempt(t, running) {
|
||||||
|
if running.LastPreemptAt.IsZero() || time.Since(running.LastPreemptAt) >= preemptCooldown {
|
||||||
|
arm = true
|
||||||
|
s.preemptArmed = true
|
||||||
|
s.preemptLevel = t.Level
|
||||||
|
s.stats.bumpInterruptLevel(&s.stats.PreemptsByLevel, t.Level)
|
||||||
|
s.setImmediateLocked(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !arm {
|
||||||
|
s.enqueueInterruptLocked(t)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if !arm {
|
||||||
|
s.signalWake()
|
||||||
|
}
|
||||||
|
return arm
|
||||||
|
}
|
||||||
|
|
||||||
|
// preemptGrantedFor 报告运行任务是否应在当前安全点让位。
|
||||||
|
func (s *scheduler) preemptGrantedFor() bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if !s.preemptArmed || s.running == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s.preemptLevel > effectiveLevel(s.running)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *scheduler) clearPreempt() {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.preemptArmed = false
|
||||||
|
s.preemptLevel = 0
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// suspend 保存现场。
|
||||||
|
//
|
||||||
|
// 深度上界是**结构推论**(= 中断级数),不是配置项:安全点上的 canSuspend 已提前
|
||||||
|
// 拦下超限情况,此处仅在竞态下兜底计数——绝不丢弃帧(帧丢了会丢副作用记录)。
|
||||||
|
func (s *scheduler) suspend(t *Task, f *TaskFrame) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if len(s.suspendStack) >= s.maxInterruptFrames {
|
||||||
|
s.stats.Rejected++
|
||||||
|
}
|
||||||
|
s.suspendStack = append(s.suspendStack, &suspendedTask{Task: t, Frame: f})
|
||||||
|
s.stats.Suspended++
|
||||||
|
// 饥饿防护:抢占计数 +1(提升有效级)并记录冷却起点。
|
||||||
|
t.PreemptCount++
|
||||||
|
t.LastPreemptAt = time.Now()
|
||||||
|
if s.running == t {
|
||||||
|
s.running = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// D1=B:中断任务在上一个任务之前的完整状态上开始运行,
|
||||||
|
// 因此这里**不**把被打断任务的任何内容交给它。
|
||||||
|
s.preemptArmed = false
|
||||||
|
s.preemptLevel = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// canSuspend 报告还有下潜余量(安全点用它决定是否真的让位)。
|
||||||
|
func (s *scheduler) canSuspend() bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.suspendStack) < s.maxInterruptFrames
|
||||||
|
}
|
||||||
|
|
||||||
|
// done 标记任务执行结束。
|
||||||
|
func (s *scheduler) done(t *Task) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.running == t {
|
||||||
|
s.running = nil
|
||||||
|
}
|
||||||
|
// 任务正常结束:让位信号不再有意义(中断已在中断队列/immediate 里)。
|
||||||
|
s.preemptArmed = false
|
||||||
|
s.preemptLevel = 0
|
||||||
|
s.stats.Executed++
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentLevel 返回当前正在执行任务的级别;无 running 时为默认级。
|
||||||
|
//
|
||||||
|
// 用于在 prepare 段把级别写进帧(抢占比较的基准)。
|
||||||
|
// interruptLevel 返回一次**中断注入**的级别。
|
||||||
|
//
|
||||||
|
// 级别是“这项工作有多不能等”,由来源在 InjectOptions.Priority 里声明
|
||||||
|
// (排队注入没有级别,它们的 TaskClass 是 TaskQueued)。
|
||||||
|
//
|
||||||
|
// privileged 表示来源是**内核级插件**(编译期内置插件,见 isKernelLevelSource):
|
||||||
|
// - privileged=true → 可用到 L4(实现“立即打断”,如 WebUI 终止按钮)
|
||||||
|
// - privileged=false → 夹到 L1..L3;空/非法一律降级为 DefaultLevel(L1)
|
||||||
|
//
|
||||||
|
// 另有完全绕过本函数的 L4 来源:内核自身的 raiseKernelInterrupt(panic / selfip)。
|
||||||
|
func interruptLevel(evt *agentIO.InputEvent, privileged bool) Level {
|
||||||
|
if evt == nil || evt.Payload == nil {
|
||||||
|
return DefaultLevel
|
||||||
|
}
|
||||||
|
raw, _ := evt.Payload["priority"].(string)
|
||||||
|
l, ok := parseInterruptLevel(raw)
|
||||||
|
if !ok {
|
||||||
|
return DefaultLevel
|
||||||
|
}
|
||||||
|
if privileged {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
return clampPluginLevel(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isKernelLevelSource 报告某来源是否是**内核级插件**(编译期内置插件)。
|
||||||
|
//
|
||||||
|
// 只有它们能声明 L4(见 interruptLevel)。判据是插件注册表里的“内置工厂”,
|
||||||
|
// 而不是插件自报的名字本身——外部插件经 proc 桥时已被夹到 L3,这里是第二道闸。
|
||||||
|
//
|
||||||
|
// source 的约定是 `插件名` 或 `插件名/实例`(如 webui/<deviceID>),故取第一段。
|
||||||
|
func (a *Agent) isKernelLevelSource(source string) bool {
|
||||||
|
if source == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
name := source
|
||||||
|
if i := strings.IndexByte(name, '/'); i > 0 {
|
||||||
|
name = name[:i]
|
||||||
|
}
|
||||||
|
// ① 编译期内置插件(根 agent 的 L4 来源之一)。
|
||||||
|
if a.pluginReg != nil && a.pluginReg.IsBuiltinPlugin(name) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// ② **本 agent 的上级**(驻留子的父)—— 设计 §6.1 的 L4 通则:
|
||||||
|
// 子的阶梯上只有父能产生 L4,所以父的"发送消息"一定能打断子。
|
||||||
|
return a.kernelSource != "" && name == a.kernelSource
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseInterruptLevel 解析插件声明的级别字符串("L1".."L3")。
|
||||||
|
// 只认字面量:拼写错误必须降级成默认级而不是被静默当成别的级别。
|
||||||
|
func parseInterruptLevel(s string) (Level, bool) {
|
||||||
|
switch s {
|
||||||
|
case "L1", "l1":
|
||||||
|
return LevelBackground, true
|
||||||
|
case "L2", "l2":
|
||||||
|
return LevelMessage, true
|
||||||
|
case "L3", "l3":
|
||||||
|
return LevelInteractive, true
|
||||||
|
case "L4", "l4":
|
||||||
|
// 内核级:解析出来但会被 clamp 夹到 L3。
|
||||||
|
return LevelCritical, true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newInputTask 把一个**排队输入**包装成任务(无级别)。
|
||||||
|
func newInputTask(evt *agentIO.InputEvent) *Task {
|
||||||
|
return &Task{Class: TaskQueued, Kind: TaskKindInput, Event: evt, EnqueuedAt: time.Now()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newInterruptTask 把一个中断请求包装成任务(带级别)。
|
||||||
|
func newInterruptTask(evt *agentIO.InputEvent, level Level) *Task {
|
||||||
|
return &Task{Class: TaskInterrupt, Kind: TaskKindInput, Level: level, Event: evt, EnqueuedAt: time.Now()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSelfTask 包装内核自循环输入——它是**排队任务**:记忆整理/子任务通知
|
||||||
|
// 不需要及时处理,可被任何中断打断。
|
||||||
|
func newSelfTask(msg selfInputMsg) *Task {
|
||||||
|
return &Task{Class: TaskQueued, Kind: TaskKindSelf, Self: msg, EnqueuedAt: time.Now()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newKernelInterruptTask 构造一个**内核级中断**(L4)。
|
||||||
|
//
|
||||||
|
// 这是 L4 的唯一来源:panic 中断、内核事件中断(selfip)。
|
||||||
|
// 插件永远拿不到这个入口——它不经 InjectOptions,也不经 proc 桥。
|
||||||
|
func newKernelInterruptTask(evt *agentIO.InputEvent) *Task {
|
||||||
|
return &Task{Class: TaskInterrupt, Kind: TaskKindInput, Level: LevelCritical, Event: evt, EnqueuedAt: time.Now()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DumpScheduler 返回调度器的原子快照(供状态页/测试断言)。
|
||||||
|
func (a *Agent) DumpScheduler() SchedulerSnapshot {
|
||||||
|
if a.sched == nil {
|
||||||
|
return SchedulerSnapshot{}
|
||||||
|
}
|
||||||
|
a.sched.mu.Lock()
|
||||||
|
defer a.sched.mu.Unlock()
|
||||||
|
snap := SchedulerSnapshot{Running: a.sched.running, Stats: a.sched.stats}
|
||||||
|
snap.Queue = append(snap.Queue, a.sched.queue...)
|
||||||
|
snap.Immediate = a.sched.immediate
|
||||||
|
for lv := LevelBackground; lv <= LevelCritical; lv++ {
|
||||||
|
snap.InterruptQueues[lv] = append(snap.InterruptQueues[lv], a.sched.interruptQueues[lv]...)
|
||||||
|
snap.PendingInterrupts = append(snap.PendingInterrupts, a.sched.interruptQueues[lv]...)
|
||||||
|
}
|
||||||
|
if a.sched.immediate != nil {
|
||||||
|
snap.PendingInterrupts = append(snap.PendingInterrupts, a.sched.immediate)
|
||||||
|
}
|
||||||
|
snap.SuspendStack = append(snap.SuspendStack, a.sched.suspendStack...)
|
||||||
|
snap.MaxInterruptFrames = a.sched.maxInterruptFrames
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
|
||||||
|
// schedulerLoop 是唯一的任务执行者(取代原 eventLoop 的输入处理)。
|
||||||
|
func (a *Agent) schedulerLoop() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[agent] schedulerLoop panic recovered: %v\n%s", r, debug.Stack())
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
go a.schedulerLoop()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
a.pumpInbox()
|
||||||
|
|
||||||
|
t, f, kind := a.sched.nextRef()
|
||||||
|
if kind == nextNone {
|
||||||
|
// 无待办:阻塞等新输入、新中断(wake)或退出。
|
||||||
|
select {
|
||||||
|
case evt := <-a.io.InputChan():
|
||||||
|
a.sched.enqueue(newInputTask(evt))
|
||||||
|
case msg := <-a.selfInputCh:
|
||||||
|
a.sched.enqueue(newSelfTask(msg))
|
||||||
|
case <-a.sched.wake:
|
||||||
|
// 中断已入 pendingInterrupts,回到循环顶部重新挑选。
|
||||||
|
case <-a.ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if kind == nextSuspended {
|
||||||
|
a.resumeTask(t, f)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.executeNewTask(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pumpInbox 把 channel 里**已就绪**的输入搬进就绪队列(非阻塞)。
|
||||||
|
//
|
||||||
|
// 为什么不直接边收边执行:先把已到达的输入收进队列,选择函数才有意义——
|
||||||
|
// M3 起抢占必然要看"队列里还压着什么",而 channel 不是可枚举的结构。
|
||||||
|
//
|
||||||
|
// 队列满即停止泵入(背压落回 channel,语义与设计文档 §4.4 一致)。
|
||||||
|
func (a *Agent) pumpInbox() {
|
||||||
|
for a.sched.hasRoom() {
|
||||||
|
select {
|
||||||
|
case evt := <-a.io.InputChan():
|
||||||
|
a.sched.enqueue(newInputTask(evt))
|
||||||
|
case msg := <-a.selfInputCh:
|
||||||
|
a.sched.enqueue(newSelfTask(msg))
|
||||||
|
case <-a.ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeTask 执行一个任务(测试与旧调用方的入口);见 executeNewTask。
|
||||||
|
// raiseKernelInterrupt 是 **L4 的唯一入口**:panic 中断与内核事件中断(selfip)。
|
||||||
|
//
|
||||||
|
// 它不经 io.InputChan(那是外部/插件输入),而是直接向调度器登记一条内核中断:
|
||||||
|
// 级别固定 L4、不夹取、不受插件声明影响。这正是“L4 只有内核持有”的落点。
|
||||||
|
//
|
||||||
|
// 能否抢占由调度器按统一判据决定;若会抢占,则顺手取消可取消的 LLM 流式步骤
|
||||||
|
// (与 interceptLoop 对插件中断的处理完全一致)。
|
||||||
|
func (a *Agent) raiseKernelInterrupt(source, channel, text string) {
|
||||||
|
if a.sched == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
evt := &agentIO.InputEvent{
|
||||||
|
Source: source,
|
||||||
|
Type: "interrupt",
|
||||||
|
OutputChannel: channel,
|
||||||
|
Payload: map[string]interface{}{
|
||||||
|
"content": text,
|
||||||
|
"interrupt": true,
|
||||||
|
"interrupt_source": source,
|
||||||
|
"interrupt_channel": channel,
|
||||||
|
"kernel": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if a.sched.requestKernelPreempt(evt) {
|
||||||
|
a.cancelCurrentLLM()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportTaskPanic 把一个任务 panic 报告成内核 L4 中断。
|
||||||
|
//
|
||||||
|
// 递归保护是**结构性**的:若 panic 的任务本身就是 L4 内核中断,则不再产生新的
|
||||||
|
// L4——否则同一个 panic 会自我放大成中断风暴,与“内核事件”应有的语义相反。
|
||||||
|
func (a *Agent) reportTaskPanic(t *Task, r interface{}) {
|
||||||
|
if t.Class == TaskInterrupt && t.Level >= LevelCritical {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.raiseKernelInterrupt("kernel", "kernel",
|
||||||
|
fmt.Sprintf("内核事件:任务 #%d 发生 panic:%v(该任务已被丢弃,调度器存活)", t.ID, r))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) executeTask(t *Task) {
|
||||||
|
a.executeNewTask(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeNewTask 执行一个**新建**任务,并做任务级 panic 隔离(不变量 I6)。
|
||||||
|
//
|
||||||
|
// 与改造前的差异(有意):原 eventLoop 在 panic 后重启整个循环,
|
||||||
|
// 现在一个任务的 panic 只丢弃该任务,调度器与其它任务不受影响。
|
||||||
|
func (a *Agent) executeNewTask(t *Task) {
|
||||||
|
var f *TaskFrame
|
||||||
|
var out stepOutcome = outcomeDone
|
||||||
|
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[agent] task#%d (%s) panic recovered: %v\n%s",
|
||||||
|
t.ID, t.Level, r, debug.Stack())
|
||||||
|
a.reportTaskPanic(t, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
switch t.Kind {
|
||||||
|
case TaskKindInput:
|
||||||
|
f, out = a.runInputTask(t.Event)
|
||||||
|
case TaskKindSelf:
|
||||||
|
f, out = a.runInputTask(selfEvent(t.Self))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if out == outcomeSuspended && f != nil {
|
||||||
|
a.sched.suspend(t, f)
|
||||||
|
a.publishEvent(events.EventScheduler, map[string]interface{}{
|
||||||
|
"action": "suspend", "task": t.ID, "level": int(t.Level),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.sched.done(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resumeTask 从保存的现场继续一个被抢占的任务。
|
||||||
|
//
|
||||||
|
// 关键:不重建帧、不重跑 prepare 段——否则会重复提交上下文与事件。
|
||||||
|
// resumeTask 从保存的现场继续一个被抢占的任务。
|
||||||
|
//
|
||||||
|
// 关键:不重跑 prepare 段(否则会重复提交上下文与事件),而是先把基础前缀
|
||||||
|
// 重建到「中断任务之上」,再把本任务自己的现场接回去(见 rebaseFramePrefix)。
|
||||||
|
func (a *Agent) resumeTask(t *Task, f *TaskFrame) {
|
||||||
|
a.sched.mu.Lock()
|
||||||
|
a.sched.stats.Resumed++
|
||||||
|
a.sched.mu.Unlock()
|
||||||
|
a.publishEvent(events.EventScheduler, map[string]interface{}{
|
||||||
|
"action": "resume", "task": t.ID, "level": int(t.Level),
|
||||||
|
})
|
||||||
|
a.rebaseFramePrefix(f)
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[agent] resume task#%d panic recovered: %v\n%s",
|
||||||
|
t.ID, r, debug.Stack())
|
||||||
|
a.reportTaskPanic(t, r)
|
||||||
|
a.sched.done(t)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
out := a.runTaskSteps(f)
|
||||||
|
if out == outcomeSuspended {
|
||||||
|
a.sched.suspend(t, f)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.finishInputTask(f, out)
|
||||||
|
a.sched.done(t)
|
||||||
|
}
|
||||||
91
internal/agent/core/scheduler_channel_routing_test.go
Normal file
91
internal/agent/core/scheduler_channel_routing_test.go
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// 抢占场景下的**输出通道路由**:被打断任务恢复后,回复必须回到它自己的输出通道。
|
||||||
|
//
|
||||||
|
// 回归判据(做驻留式子 agent 前必须成立):**内核不持有"当前通道"可变状态**。
|
||||||
|
// 曾经有 agent 级字段 a.currentOutputChannel:只在 prepare 段写入,而被打断任务
|
||||||
|
// 恢复时不重新 prepare(resumeTask 只 rebase 前缀),于是中断任务 prepare 时把它
|
||||||
|
// 覆盖成自己的通道,被恢复的任务再把回复发到**中断任务的通道**上——两任务串台。
|
||||||
|
// N0 已删除该字段:通道一律从输入事件/帧推导(outputChannelOf / f.OutputChannel)。
|
||||||
|
//
|
||||||
|
// 每任务回执(evt.ResponseCh,Target=evt.Source)不受影响,所以既有测试全绿;
|
||||||
|
// 但按通道投递(OutputEvent.OutputChannel / events.EventAgentOutput 的 channel)
|
||||||
|
// 是插件渲染给用户的路径,它会串。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPreempt_ResumeKeepsOwnOutputChannel(t *testing.T) {
|
||||||
|
sp := newPreemptProvider("intr-done", "low-done")
|
||||||
|
a := newPreemptAgent(t, sp)
|
||||||
|
|
||||||
|
// 排队任务,来源与输出通道都是 qq。
|
||||||
|
lowEvt, lowCh := textEvent("qq", "低优先级任务")
|
||||||
|
lowTask := newInputTask(lowEvt)
|
||||||
|
if !a.sched.enqueue(lowTask) {
|
||||||
|
t.Fatal("入队失败")
|
||||||
|
}
|
||||||
|
if lowEvt.OutputChannel != "qq" {
|
||||||
|
t.Fatalf("前置条件不成立:OutputChannel=%q", lowEvt.OutputChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
lt, _, kind := a.sched.nextRef()
|
||||||
|
if kind != nextReady {
|
||||||
|
t.Fatalf("应取到排队任务,kind=%v", kind)
|
||||||
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { a.executeNewTask(lt); close(done) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-sp.entered:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("provider 未被调用")
|
||||||
|
}
|
||||||
|
|
||||||
|
// cli 中断抢占(L3):任务被挂起。
|
||||||
|
intrEvt, intrCh := textEvent("cli", "紧急打断")
|
||||||
|
intrEvt.Payload["interrupt"] = true
|
||||||
|
if !a.sched.requestPreempt(intrEvt, LevelInteractive) {
|
||||||
|
t.Fatal("L3 中断应能抢占排队任务")
|
||||||
|
}
|
||||||
|
a.cancelCurrentLLM()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("被打断任务未挂起")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中断任务先运行(它会 prepare,通道是 cli)。
|
||||||
|
it, _, k := a.sched.nextRef()
|
||||||
|
if k != nextImmediate {
|
||||||
|
t.Fatalf("应取到立即运行的中断,kind=%v", k)
|
||||||
|
}
|
||||||
|
a.executeNewTask(it)
|
||||||
|
if len(intrCh) != 1 {
|
||||||
|
t.Fatalf("中断任务应回执一次,实际 %d", len(intrCh))
|
||||||
|
}
|
||||||
|
// 注意:这里**刻意**不再有任何"内核当前通道"可断言 —— 该字段已删除,
|
||||||
|
// 通道只跟着输入事件与帧走。下面断言的就是这个性质本身。
|
||||||
|
|
||||||
|
// 恢复被抢占任务:它不重新 prepare,只能靠帧里记着自己的通道。
|
||||||
|
rt, rf, k2 := a.sched.nextRef()
|
||||||
|
if k2 != nextSuspended {
|
||||||
|
t.Fatalf("应恢复被抢占任务,kind=%v", k2)
|
||||||
|
}
|
||||||
|
a.resumeTask(rt, rf)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case out := <-lowCh:
|
||||||
|
if out.OutputChannel != "qq" {
|
||||||
|
t.Fatalf("被打断任务恢复后的输出通道=%q,期望 qq —— 被中断任务的通道覆盖了 agent 级字段(两任务串台)",
|
||||||
|
out.OutputChannel)
|
||||||
|
}
|
||||||
|
if out.Target != "qq" {
|
||||||
|
t.Fatalf("回执 Target=%q,期望 qq", out.Target)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("被恢复任务未回执")
|
||||||
|
}
|
||||||
|
}
|
||||||
167
internal/agent/core/scheduler_critical_test.go
Normal file
167
internal/agent/core/scheduler_critical_test.go
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// M4 验收测试:临界区语义显式化 + 抢占延迟到安全点 + 批次不再被中断放弃。
|
||||||
|
//
|
||||||
|
// 设计依据 docs/zh/input-scheduler-design.md §4.3(临界区)、§11.1(P5/P6)。
|
||||||
|
//
|
||||||
|
// 关键结构事实:让位检查**只在 step 之间**进行,因此任何正在执行的 step
|
||||||
|
// (工具 RPC、ONNX、CAS 落盘)天然不可抢占——中断只能等它返回。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// P5/P6:工具执行期间到达的高优先级中断不得立即抢占;必须等工具返回后的安全点。
|
||||||
|
func TestPreempt_DeferredDuringToolExec(t *testing.T) {
|
||||||
|
sh := NewStageHost()
|
||||||
|
entered := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
var once sync.Once
|
||||||
|
sh.RegisterTool("t_slow", sdk.ToolDef{Name: "t_slow", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
once.Do(func() { close(entered) })
|
||||||
|
<-release
|
||||||
|
return "slow-done", nil
|
||||||
|
})
|
||||||
|
|
||||||
|
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
|
||||||
|
{Content: "", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_slow")}}, // 低优先级任务调用慢工具
|
||||||
|
{Content: "low-done"}, // 恢复后收尾
|
||||||
|
{Content: "intr-done"}, // 中断任务
|
||||||
|
}}
|
||||||
|
a := New(AgentConfig{
|
||||||
|
ID: "crit",
|
||||||
|
Provider: sp,
|
||||||
|
ProviderManager: agentAPI.NewProviderManager(),
|
||||||
|
IO: agentIO.NewIOManager(),
|
||||||
|
StageHost: sh,
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, _ = enqueueQueued(t, a, "qq", "低优先级任务"); true {
|
||||||
|
}
|
||||||
|
lt, _, _ := a.sched.nextRef()
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { a.executeNewTask(lt); close(done) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-entered:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("慢工具未被调用")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具执行中注入 L4 中断。
|
||||||
|
intrEvt, _ := textEvent("cli", "紧急打断")
|
||||||
|
intrEvt.Payload["interrupt"] = true
|
||||||
|
if !a.sched.requestKernelPreempt(intrEvt) {
|
||||||
|
t.Fatal("L4 应 arm 让位信号")
|
||||||
|
}
|
||||||
|
// 关键断言:信号已 arm,但任务仍在工具里 —— 绝不能挂起。
|
||||||
|
if !a.sched.preemptGrantedFor() {
|
||||||
|
t.Fatal("让位信号应已 arm")
|
||||||
|
}
|
||||||
|
if a.DumpScheduler().Running == nil {
|
||||||
|
t.Fatal("工具执行中不得挂起(StepToolExec 是临界区)")
|
||||||
|
}
|
||||||
|
if len(a.DumpScheduler().SuspendStack) != 0 {
|
||||||
|
t.Fatal("工具执行中 suspendStack 应为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 放行工具 → 工具返回后的安全点才挂起。
|
||||||
|
close(release)
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("工具返回后未挂起")
|
||||||
|
}
|
||||||
|
|
||||||
|
snap := a.DumpScheduler()
|
||||||
|
if len(snap.SuspendStack) != 1 {
|
||||||
|
t.Fatalf("工具返回后 suspendStack=%d,期望 1", len(snap.SuspendStack))
|
||||||
|
}
|
||||||
|
if snap.SuspendStack[0].Frame.Step != StepToolAfter {
|
||||||
|
t.Fatalf("应在工具执行后的安全点挂起(StepToolAfter),实际 %v", snap.SuspendStack[0].Frame.Step)
|
||||||
|
}
|
||||||
|
if len(snap.PendingInterrupts) != 1 {
|
||||||
|
t.Fatalf("中断请求不得丢失,pendingInterrupts=%d", len(snap.PendingInterrupts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// _consolidation_ 整任务视为不可抢占(它直接改图库)。
|
||||||
|
func TestCriticalSection_ConsolidationMarked(t *testing.T) {
|
||||||
|
a := newPreemptAgent(t, newPreemptProvider())
|
||||||
|
|
||||||
|
// 判定:只有记忆整理通道是整任务临界区。
|
||||||
|
if isCriticalChannel("cli") {
|
||||||
|
t.Fatal("普通通道不应被判为临界区")
|
||||||
|
}
|
||||||
|
if !isCriticalChannel(channelConsolidation) {
|
||||||
|
t.Fatal("记忆整理必须是不可抢占临界区")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 集成:标志的推导链「输入事件 → 通道 → isCriticalChannel → scheduler.critical」
|
||||||
|
// 必须成立(N0 之后通道只从事件推导,不再有内核可变字段)。
|
||||||
|
for _, c := range []struct {
|
||||||
|
channel string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"cli", false},
|
||||||
|
{channelConsolidation, true},
|
||||||
|
} {
|
||||||
|
evt, _ := textEvent("tc", "x")
|
||||||
|
evt.OutputChannel = c.channel
|
||||||
|
a.sched.setCritical(isCriticalChannel(outputChannelOf(evt)))
|
||||||
|
if got := a.sched.inCritical(); got != c.want {
|
||||||
|
t.Fatalf("通道 %q → critical=%v,期望 %v", c.channel, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新语义:没有抢占时,同批的多个工具必须全部执行——不再有「中断放弃剩余批」。
|
||||||
|
func TestBatch_NotAbandonedWithoutPreemption(t *testing.T) {
|
||||||
|
sh := NewStageHost()
|
||||||
|
var mu sync.Mutex
|
||||||
|
var ran []string
|
||||||
|
reg := func(name string) {
|
||||||
|
sh.RegisterTool(name, sdk.ToolDef{Name: name, Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
mu.Lock()
|
||||||
|
ran = append(ran, name)
|
||||||
|
mu.Unlock()
|
||||||
|
return name + "-out", nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
reg("t_a")
|
||||||
|
reg("t_b")
|
||||||
|
|
||||||
|
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
|
||||||
|
{Content: "", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_a"), tc("c2", "t_b")}},
|
||||||
|
{Content: "全部完成"},
|
||||||
|
}}
|
||||||
|
a := New(AgentConfig{
|
||||||
|
ID: "batch",
|
||||||
|
Provider: sp,
|
||||||
|
ProviderManager: agentAPI.NewProviderManager(),
|
||||||
|
IO: agentIO.NewIOManager(),
|
||||||
|
StageHost: sh,
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, _ = enqueueQueued(t, a, "cli", "跑两个工具"); true {
|
||||||
|
}
|
||||||
|
tt, _, _ := a.sched.nextRef()
|
||||||
|
a.executeNewTask(tt)
|
||||||
|
|
||||||
|
if len(ran) != 2 || ran[0] != "t_a" || ran[1] != "t_b" {
|
||||||
|
t.Fatalf("同批工具应全部按序执行,实际 %v", ran)
|
||||||
|
}
|
||||||
|
snap := a.DumpScheduler()
|
||||||
|
if len(snap.SuspendStack) != 0 || len(snap.PendingInterrupts) != 0 {
|
||||||
|
t.Fatalf("无抢占时不应有挂起或待处理中断:%+v", snap)
|
||||||
|
}
|
||||||
|
if snap.Stats.Executed != 1 {
|
||||||
|
t.Fatalf("Executed=%d,期望 1", snap.Stats.Executed)
|
||||||
|
}
|
||||||
|
}
|
||||||
222
internal/agent/core/scheduler_e2e_test.go
Normal file
222
internal/agent/core/scheduler_e2e_test.go
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// M7 验收测试:可观测性 + 压力 + 端到端。
|
||||||
|
//
|
||||||
|
// 设计依据 docs/zh/input-scheduler-design.md §11.5(O1/O2)、§11.6(E1/E2)。
|
||||||
|
//
|
||||||
|
// 这一组与前几组的区别:前几组直接驱动调度器(确定性、可断言内部状态),
|
||||||
|
// 这一组**完整启动** schedulerLoop + interceptLoop,经真实 channel 投递,
|
||||||
|
// 验证组装后的行为与不变量。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||||
|
)
|
||||||
|
|
||||||
|
// countingProvider 只统计调用次数,永远成功。
|
||||||
|
type countingProvider struct{ n atomic.Int64 }
|
||||||
|
|
||||||
|
func (p *countingProvider) Name() string { return "counting" }
|
||||||
|
func (p *countingProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||||
|
p.n.Add(1)
|
||||||
|
return &agentAPI.CompletionResponse{Content: "ok"}, nil
|
||||||
|
}
|
||||||
|
func (p *countingProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||||
|
return nil, errors.New("counting provider: no stream")
|
||||||
|
}
|
||||||
|
func (p *countingProvider) MaxContextTokens() int { return 8192 }
|
||||||
|
|
||||||
|
func waitQuiescent(t *testing.T, a *Agent, wantExecuted uint64, timeout time.Duration) SchedulerSnapshot {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for {
|
||||||
|
snap := a.DumpScheduler()
|
||||||
|
if snap.Running == nil && len(snap.Queue) == 0 &&
|
||||||
|
len(snap.PendingInterrupts) == 0 && len(snap.SuspendStack) == 0 &&
|
||||||
|
snap.Stats.Executed >= wantExecuted {
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("未在 %v 内排空:running=%v queue=%d pending=%d suspend=%d executed=%d",
|
||||||
|
timeout, snap.Running != nil, len(snap.Queue), len(snap.PendingInterrupts),
|
||||||
|
len(snap.SuspendStack), snap.Stats.Executed)
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 压力:N 个排队输入 + M 个中断,全部经真实 loop 执行,结束时三集合必须排空。
|
||||||
|
func TestScheduler_StressMixedLoad(t *testing.T) {
|
||||||
|
sp := &countingProvider{}
|
||||||
|
a := New(AgentConfig{
|
||||||
|
ID: "stress",
|
||||||
|
Provider: sp,
|
||||||
|
ProviderManager: agentAPI.NewProviderManager(),
|
||||||
|
IO: agentIO.NewIOManager(),
|
||||||
|
StageHost: NewStageHost(),
|
||||||
|
})
|
||||||
|
a.Start()
|
||||||
|
defer a.Stop()
|
||||||
|
|
||||||
|
const nInputs = 200
|
||||||
|
const nInterrupts = 50
|
||||||
|
|
||||||
|
for i := 0; i < nInputs; i++ {
|
||||||
|
a.io.InjectInput("cli", "text", map[string]interface{}{"content": fmt.Sprintf("msg-%d", i)})
|
||||||
|
}
|
||||||
|
// 中断按 L1/L2/L3 轮转:把“四条中断队列按级别高→低扫描”真正压上,
|
||||||
|
// 而不只是排空一条队列。
|
||||||
|
levels := []string{"L1", "L2", "L3"}
|
||||||
|
for i := 0; i < nInterrupts; i++ {
|
||||||
|
a.io.InjectInterruptTextOpts("qq", "cli", fmt.Sprintf("intr-%d", i),
|
||||||
|
agentIO.InjectOptions{Priority: levels[i%len(levels)]})
|
||||||
|
}
|
||||||
|
|
||||||
|
snap := waitQuiescent(t, a, nInputs+nInterrupts, 30*time.Second)
|
||||||
|
|
||||||
|
if got := sp.n.Load(); got != int64(nInputs+nInterrupts) {
|
||||||
|
t.Fatalf("LLM 调用=%d,期望 %d(每条输入/中断恰好一次)", got, nInputs+nInterrupts)
|
||||||
|
}
|
||||||
|
if snap.Stats.Rejected != 0 {
|
||||||
|
t.Fatalf("容量充足却出现 Rejected=%d,说明背压/深度判定有误", snap.Stats.Rejected)
|
||||||
|
}
|
||||||
|
// 上次快照的计数在排空后应当稳定(不丢不重):等于入队后的执行数。
|
||||||
|
if snap.Stats.Executed != uint64(nInputs+nInterrupts) {
|
||||||
|
t.Fatalf("Executed=%d,期望 %d", snap.Stats.Executed, nInputs+nInterrupts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// O2:每次挂起/恢复都产生一条 scheduler 事件。
|
||||||
|
func TestObservability_SchedulerEventsAndStatus(t *testing.T) {
|
||||||
|
bus := events.NewBus()
|
||||||
|
var mu sync.Mutex
|
||||||
|
var actions []string
|
||||||
|
bus.Subscribe(events.EventScheduler, func(e *events.Event) {
|
||||||
|
mu.Lock()
|
||||||
|
actions = append(actions, fmt.Sprint(e.Payload["action"]))
|
||||||
|
mu.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
sp := newPreemptProvider("intr-done", "low-done")
|
||||||
|
a := New(AgentConfig{
|
||||||
|
ID: "obs",
|
||||||
|
Provider: sp,
|
||||||
|
ProviderManager: agentAPI.NewProviderManager(),
|
||||||
|
IO: agentIO.NewIOManager(),
|
||||||
|
StageHost: NewStageHost(),
|
||||||
|
EventBus: bus,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 直接驱动一次抢占-挂起-恢复(与 M3b 相同的手法)。
|
||||||
|
lowEvt, _ := textEvent("qq", "低优先级")
|
||||||
|
lowTask := &Task{Kind: TaskKindInput, Level: LevelBackground, Event: lowEvt, EnqueuedAt: time.Now()}
|
||||||
|
a.sched.enqueue(lowTask)
|
||||||
|
lt, _, _ := a.sched.nextRef()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { a.executeNewTask(lt); close(done) }()
|
||||||
|
select {
|
||||||
|
case <-sp.entered:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("provider 未进入")
|
||||||
|
}
|
||||||
|
|
||||||
|
intrEvt, _ := textEvent("cli", "紧急")
|
||||||
|
intrEvt.Payload["interrupt"] = true
|
||||||
|
a.sched.requestKernelPreempt(intrEvt)
|
||||||
|
a.cancelCurrentLLM()
|
||||||
|
<-done
|
||||||
|
|
||||||
|
it, _, _ := a.sched.nextRef()
|
||||||
|
a.executeNewTask(it)
|
||||||
|
rt, rf, _ := a.sched.nextRef()
|
||||||
|
a.resumeTask(rt, rf)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
got := strings.Join(actions, ",")
|
||||||
|
mu.Unlock()
|
||||||
|
if !strings.Contains(got, "suspend") || !strings.Contains(got, "resume") {
|
||||||
|
t.Fatalf("调度事件缺失:%q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态快照(供状态页/诊断):计数一致、三集合为空。
|
||||||
|
st := a.GetKernelStatus().Scheduler
|
||||||
|
if st.SuspendStack != 0 || st.PendingInterrupts != 0 || st.ReadyQueueDepth != 0 {
|
||||||
|
t.Fatalf("排空后状态非空:%+v", st)
|
||||||
|
}
|
||||||
|
if st.Suspended == 0 || st.Resumed == 0 {
|
||||||
|
t.Fatalf("挂起/恢复计数缺失:%+v", st)
|
||||||
|
}
|
||||||
|
if st.Executed < 2 {
|
||||||
|
t.Fatalf("Executed=%d,期望 >=2", st.Executed)
|
||||||
|
}
|
||||||
|
if st.MaxSuspendDepth != 4 {
|
||||||
|
t.Fatalf("MaxSuspendDepth=%d,期望 4", st.MaxSuspendDepth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// E1/E2:完整启动 loop,经真实 channel 投递 L1 任务与 L4 中断,
|
||||||
|
// 断言「LLM 流式中断 → 挂起 → 中断先完成 → 原任务恢复」的整条链路。
|
||||||
|
func TestE2E_RealLoopPreemption(t *testing.T) {
|
||||||
|
bus := events.NewBus()
|
||||||
|
var mu sync.Mutex
|
||||||
|
var actions []string
|
||||||
|
bus.Subscribe(events.EventScheduler, func(e *events.Event) {
|
||||||
|
mu.Lock()
|
||||||
|
actions = append(actions, fmt.Sprint(e.Payload["action"]))
|
||||||
|
mu.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
sp := newPreemptProvider("intr-done", "low-done")
|
||||||
|
a := New(AgentConfig{
|
||||||
|
ID: "e2e",
|
||||||
|
Provider: sp,
|
||||||
|
ProviderManager: agentAPI.NewProviderManager(),
|
||||||
|
IO: agentIO.NewIOManager(),
|
||||||
|
StageHost: NewStageHost(),
|
||||||
|
EventBus: bus,
|
||||||
|
})
|
||||||
|
a.Start()
|
||||||
|
defer a.Stop()
|
||||||
|
|
||||||
|
// 排队输入:qq 入站消息 → 阻塞在第一次 LLM 调用(排队任务无级别)
|
||||||
|
a.io.InjectInput("qq", "text", map[string]interface{}{"content": "长任务"})
|
||||||
|
select {
|
||||||
|
case <-sp.entered:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("低优先级任务未进入 LLM")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插件声明的 L3 中断:interceptLoop 应取消 LLM 并登记抢占。
|
||||||
|
// 注意它能打断**排队任务**不是因为级别高,而是因为排队任务无级别——
|
||||||
|
// 任何中断都大于它。
|
||||||
|
a.io.InjectInterruptTextOpts("cli", "cli", "紧急打断", agentIO.InjectOptions{Priority: "L3"})
|
||||||
|
a.io.InjectInput("cli", "text", map[string]interface{}{"content": "后续常规输入"})
|
||||||
|
|
||||||
|
// 排空:中断任务 + 被恢复的原任务 + 后续常规输入
|
||||||
|
snap := waitQuiescent(t, a, 3, 15*time.Second)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
got := strings.Join(actions, ",")
|
||||||
|
mu.Unlock()
|
||||||
|
if !strings.Contains(got, "suspend") || !strings.Contains(got, "resume") {
|
||||||
|
t.Fatalf("E2E 未发生抢占-挂起-恢复:%q", got)
|
||||||
|
}
|
||||||
|
if snap.Stats.Executed < 3 {
|
||||||
|
t.Fatalf("Executed=%d,期望 >=3", snap.Stats.Executed)
|
||||||
|
}
|
||||||
|
// 第一次 LLM 调用被丢弃 + 中断 1 + 恢复 1 + 常规输入 1 = 4
|
||||||
|
if sp.callCount() != 4 {
|
||||||
|
t.Fatalf("LLM 调用=%d,期望 4(丢弃 1 + 中断 1 + 恢复 1 + 常规 1)", sp.callCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
294
internal/agent/core/scheduler_kernel_test.go
Normal file
294
internal/agent/core/scheduler_kernel_test.go
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// L4 的内核独占性 + 两类别抢占规则。
|
||||||
|
//
|
||||||
|
// 模型(用户明确):
|
||||||
|
// - 类别由**用哪个注入 API** 决定,与通道名无关;
|
||||||
|
// - L1..L3 由插件在 InjectOptions.Priority 声明;
|
||||||
|
// - L4 只有内核持有(panic / 内核事件 selfip);
|
||||||
|
// - 排队输入无级别,可被**任何**中断打断。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||||
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 非内核级来源声明 L4 必须被夹到 L3;内核级来源(内置插件)可用到 L4。
|
||||||
|
func TestKernel_L4RequiresKernelLevelSource(t *testing.T) {
|
||||||
|
if got := clampPluginLevel(LevelCritical); got != LevelInteractive {
|
||||||
|
t.Fatalf("非特权声明 L4 应被夹到 L3,实际 %v", got)
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
declared string
|
||||||
|
want Level
|
||||||
|
}{
|
||||||
|
{"L1", LevelBackground},
|
||||||
|
{"L2", LevelMessage},
|
||||||
|
{"L3", LevelInteractive},
|
||||||
|
{"l2", LevelMessage},
|
||||||
|
{"L4", LevelInteractive}, // 非特权 → 夹到 L3
|
||||||
|
{"L7", DefaultLevel}, // 未知 → 默认级
|
||||||
|
{"", DefaultLevel}, // 未声明 → 默认级
|
||||||
|
{"紧急", DefaultLevel}, // 拼写错误 → 默认级(不得被静默当成别的级别)
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
evt := &agentIO.InputEvent{Payload: map[string]interface{}{}}
|
||||||
|
if c.declared != "" {
|
||||||
|
evt.Payload["priority"] = c.declared
|
||||||
|
}
|
||||||
|
if got := interruptLevel(evt, false); got != c.want {
|
||||||
|
t.Fatalf("非特权声明 %q → 级别 %v,期望 %v", c.declared, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := interruptLevel(nil, false); got != DefaultLevel {
|
||||||
|
t.Fatalf("无事件应为默认级,实际 %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 特权(内核级插件):L4 被承认,其余待遇不变。
|
||||||
|
for _, c := range []struct {
|
||||||
|
declared string
|
||||||
|
want Level
|
||||||
|
}{
|
||||||
|
{"L4", LevelCritical},
|
||||||
|
{"L3", LevelInteractive},
|
||||||
|
{"L1", LevelBackground},
|
||||||
|
{"", DefaultLevel},
|
||||||
|
{"L9", DefaultLevel},
|
||||||
|
} {
|
||||||
|
evt := &agentIO.InputEvent{Payload: map[string]interface{}{}}
|
||||||
|
if c.declared != "" {
|
||||||
|
evt.Payload["priority"] = c.declared
|
||||||
|
}
|
||||||
|
if got := interruptLevel(evt, true); got != c.want {
|
||||||
|
t.Fatalf("特权声明 %q → 级别 %v,期望 %v", c.declared, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内核级 = 插件注册表里的**内置工厂**(编译期自注册),与插件自报名无关;
|
||||||
|
// source 约定 `插件名` 或 `插件名/实例`(如 webui/<deviceID>)。
|
||||||
|
func TestKernel_KernelLevelSource(t *testing.T) {
|
||||||
|
plugin.RegisterFactory("core_test_builtin", func(string, map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
a := &Agent{pluginReg: plugin.NewRegistry()}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
source string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"core_test_builtin", true},
|
||||||
|
{"core_test_builtin/dev-1", true}, // 插件名/实例
|
||||||
|
{"core_test_external", false},
|
||||||
|
{"webui", false}, // 本测试注册表里没有 webui 工厂
|
||||||
|
{"", false},
|
||||||
|
{"core_test_builtinX", false}, // 不做前缀匹配
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := a.isKernelLevelSource(c.source); got != c.want {
|
||||||
|
t.Fatalf("source=%q → %v,期望 %v", c.source, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (&Agent{}).isKernelLevelSource("core_test_builtin") {
|
||||||
|
t.Fatal("没有插件注册表时不得授予内核级")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排队任务可被**任何**中断打断——包括最低的 L1。
|
||||||
|
func TestKernel_QueuedTaskIsPreemptedByAnyInterrupt(t *testing.T) {
|
||||||
|
s := newScheduler(8)
|
||||||
|
q := newInputTask(&agentIO.InputEvent{Source: "plugin", OutputChannel: "plugin"})
|
||||||
|
s.enqueue(q)
|
||||||
|
if task, _, kind := s.nextRef(); task != q || kind != nextReady {
|
||||||
|
t.Fatalf("应取到排队任务,kind=%v", kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
evt, _ := textEvent("qq", "最低级中断")
|
||||||
|
if !s.registerInterrupt(newInterruptTask(evt, LevelBackground)) {
|
||||||
|
t.Fatal("L1 中断也必须能打断排队任务(排队任务无级别)")
|
||||||
|
}
|
||||||
|
if s.immediate == nil || s.immediate.Level != LevelBackground {
|
||||||
|
t.Fatalf("抢占者应进 immediate 槽,实际 %+v", s.immediate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排队输入从不抢占——它没有级别,也就没有“比谁高”。
|
||||||
|
func TestKernel_QueuedInputNeverPreempts(t *testing.T) {
|
||||||
|
s := newScheduler(8)
|
||||||
|
if !s.enqueue(newInputTask(&agentIO.InputEvent{Source: "a", OutputChannel: "a"})) {
|
||||||
|
t.Fatal("入队失败")
|
||||||
|
}
|
||||||
|
s.nextRef() // running = 第一个排队任务
|
||||||
|
if s.registerInterrupt(newInputTask(&agentIO.InputEvent{Source: "b", OutputChannel: "b"})) {
|
||||||
|
t.Fatal("排队输入不得抢占任何任务")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内核 L4 入口不受夹取影响,且能抢占中断(L3)。
|
||||||
|
func TestKernel_RequestKernelPreemptUsesL4(t *testing.T) {
|
||||||
|
s := newScheduler(8)
|
||||||
|
evt, _ := textEvent("cli", "L3 运行中")
|
||||||
|
s.registerInterrupt(newInterruptTask(evt, LevelInteractive))
|
||||||
|
s.nextRef() // running = L3 中断
|
||||||
|
|
||||||
|
kevt, _ := textEvent("kernel", "panic 中断")
|
||||||
|
if !s.requestKernelPreempt(kevt) {
|
||||||
|
t.Fatal("内核 L4 应能抢占 L3 中断")
|
||||||
|
}
|
||||||
|
if s.immediate == nil || s.immediate.Level != LevelCritical {
|
||||||
|
t.Fatalf("内核中断必须是 L4,实际 %+v", s.immediate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 任务 panic → 内核 L4 中断(panic 是 L4 的来源之一)。
|
||||||
|
func TestKernel_PanicRaisesL4Interrupt(t *testing.T) {
|
||||||
|
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{{Content: "已收到内核事件"}}}
|
||||||
|
a := newPreemptAgent(t, sp)
|
||||||
|
|
||||||
|
// Event 为 nil:handleInput 解引用即 panic。
|
||||||
|
bad := newInputTask(nil)
|
||||||
|
if !a.sched.enqueue(bad) {
|
||||||
|
t.Fatal("入队失败")
|
||||||
|
}
|
||||||
|
task, _, _ := a.sched.nextRef()
|
||||||
|
a.executeTask(task) // panic 被隔离
|
||||||
|
|
||||||
|
snap := a.DumpScheduler()
|
||||||
|
if snap.Immediate == nil {
|
||||||
|
t.Fatal("panic 必须产生一条内核 L4 中断")
|
||||||
|
}
|
||||||
|
if snap.Immediate.Level != LevelCritical {
|
||||||
|
t.Fatalf("panic 中断级别=%v,期望 L4", snap.Immediate.Level)
|
||||||
|
}
|
||||||
|
text, _ := snap.Immediate.Event.Payload["content"].(string)
|
||||||
|
if !strings.Contains(text, "panic") {
|
||||||
|
t.Fatalf("panic 中断应说明发生了什么,实际 %q", text)
|
||||||
|
}
|
||||||
|
if snap.Immediate.Event.Payload["kernel"] != true {
|
||||||
|
t.Fatal("内核中断必须带 kernel 标记,便于与插件中断区分")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归保护是结构性的:L4 内核中断自己 panic 时,不再产生新的 L4。
|
||||||
|
func TestKernel_PanicInsideL4DoesNotRecurse(t *testing.T) {
|
||||||
|
sp := &scriptProvider{}
|
||||||
|
a := newPreemptAgent(t, sp)
|
||||||
|
|
||||||
|
evt, _ := textEvent("kernel", "内核事件")
|
||||||
|
l4 := newKernelInterruptTask(evt)
|
||||||
|
a.sched.immediate = l4
|
||||||
|
task, _, _ := a.sched.nextRef()
|
||||||
|
if task != l4 {
|
||||||
|
t.Fatal("应取到 L4 内核中断")
|
||||||
|
}
|
||||||
|
a.executeTask(&Task{ID: task.ID, Class: TaskInterrupt, Level: LevelCritical, Kind: TaskKindInput, Event: nil})
|
||||||
|
|
||||||
|
snap := a.DumpScheduler()
|
||||||
|
if snap.Immediate != nil || len(snap.PendingInterrupts) != 0 {
|
||||||
|
t.Fatalf("L4 自身 panic 不得再产生中断(否则自我放大),实际 immediate=%+v pending=%d",
|
||||||
|
snap.Immediate, len(snap.PendingInterrupts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中断栈的 4 帧上界是**结构推论**:排队(L0) ← I(L1) ← I(L2) ← I(L3) ← I(L4 运行中)。
|
||||||
|
func TestKernel_StackBoundIsFullChain(t *testing.T) {
|
||||||
|
s := newScheduler(16)
|
||||||
|
frame := func() *TaskFrame { return &TaskFrame{} }
|
||||||
|
chain := []struct {
|
||||||
|
class TaskClass
|
||||||
|
lv Level
|
||||||
|
}{
|
||||||
|
{TaskQueued, 0},
|
||||||
|
{TaskInterrupt, LevelBackground},
|
||||||
|
{TaskInterrupt, LevelMessage},
|
||||||
|
{TaskInterrupt, LevelInteractive},
|
||||||
|
}
|
||||||
|
for i := 0; i < len(chain)-1; i++ {
|
||||||
|
s.suspend(&Task{ID: uint64(i + 1), Class: chain[i].class, Level: chain[i].lv}, frame())
|
||||||
|
}
|
||||||
|
if !s.canSuspend() {
|
||||||
|
t.Fatal("3 帧挂起时仍应容得下 L3(第 4 级)继续下潜")
|
||||||
|
}
|
||||||
|
s.suspend(&Task{ID: 4, Class: TaskInterrupt, Level: LevelInteractive}, frame())
|
||||||
|
if s.canSuspend() {
|
||||||
|
t.Fatal("4 帧挂起 = 全链挂起(L4 运行中),不应再有下潜余量")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 端到端:插件声明 Priority → io.applyInjectOpts → payload → interruptLevel → 任务级别。
|
||||||
|
// 这条链路断在任何一环,插件声明的级别都会静默失效(降级到 L1)。
|
||||||
|
func TestKernel_PriorityFlowsThroughIOLayer(t *testing.T) {
|
||||||
|
ioM := agentIO.NewIOManager()
|
||||||
|
ioM.InjectInterruptTextOpts("qq", "cli", "通知", agentIO.InjectOptions{Priority: "L3"})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case evt := <-ioM.InputInterruptChan():
|
||||||
|
if got := interruptLevel(evt, false); got != LevelInteractive {
|
||||||
|
t.Fatalf("经 io 层后的级别=%v,期望 L3(payload=%v)", got, evt.Payload)
|
||||||
|
}
|
||||||
|
task := newInterruptTask(evt, interruptLevel(evt, false))
|
||||||
|
if task.Class != TaskInterrupt || task.Level != LevelInteractive {
|
||||||
|
t.Fatalf("中断任务类别/级别=%v/%v,期望 interrupt/L3", task.Class, task.Level)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("中断未到达 interruptCh")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排队路径带 priority 也必须无效:排队输入没有级别。
|
||||||
|
ioM.InjectTextOpts("qq", "cli", "普通输入", agentIO.InjectOptions{Priority: "L3"})
|
||||||
|
select {
|
||||||
|
case evt := <-ioM.InputChan():
|
||||||
|
task := newInputTask(evt)
|
||||||
|
if task.Class != TaskQueued || task.Level != 0 {
|
||||||
|
t.Fatalf("排队任务类别/级别=%v/%v,期望 queued/无级别", task.Class, task.Level)
|
||||||
|
}
|
||||||
|
if effectiveLevel(task) != 0 {
|
||||||
|
t.Fatalf("排队任务有效级=%v,期望 0", effectiveLevel(task))
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("排队输入未到达 inputCh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内核级插件声明的 L4 必须一路到达调度器(“立即打断”能力,如 WebUI 终止按钮)。
|
||||||
|
func TestKernel_KernelLevelPluginCanRaiseL4(t *testing.T) {
|
||||||
|
plugin.RegisterFactory("core_test_l4", func(string, map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
a := newPreemptAgent(t, &scriptProvider{})
|
||||||
|
a.pluginReg = plugin.NewRegistry()
|
||||||
|
|
||||||
|
// 先让一个排队任务跑起来(无级别),才能看到“抢占”。
|
||||||
|
evt, _ := textEvent("qq", "长任务")
|
||||||
|
if !a.sched.enqueue(newInputTask(evt)) {
|
||||||
|
t.Fatal("入队失败")
|
||||||
|
}
|
||||||
|
a.sched.nextRef()
|
||||||
|
|
||||||
|
// 内核级插件(内置)声明 L4 的终止通知。
|
||||||
|
kevt, _ := textEvent("core_test_l4", "用户按了终止按钮")
|
||||||
|
kevt.Payload["priority"] = "L4"
|
||||||
|
level := interruptLevel(kevt, a.isKernelLevelSource(kevt.Source))
|
||||||
|
if level != LevelCritical {
|
||||||
|
t.Fatalf("内核级插件声明 L4 应得 L4,实际 %v", level)
|
||||||
|
}
|
||||||
|
if !a.sched.requestPreempt(kevt, level) {
|
||||||
|
t.Fatal("L4 应能打断排队任务")
|
||||||
|
}
|
||||||
|
if a.sched.immediate == nil || a.sched.immediate.Level != LevelCritical {
|
||||||
|
t.Fatalf("应有一条 L4 中断在 immediate,实际 %+v", a.sched.immediate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 反例:同样的声明来自外部插件 → 夹到 L3。
|
||||||
|
eevt, _ := textEvent("core_test_external", "外部插件也想立即打断")
|
||||||
|
eevt.Payload["priority"] = "L4"
|
||||||
|
if got := interruptLevel(eevt, a.isKernelLevelSource(eevt.Source)); got != LevelInteractive {
|
||||||
|
t.Fatalf("外部插件声明 L4 应被夹到 L3,实际 %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
356
internal/agent/core/scheduler_preempt_test.go
Normal file
356
internal/agent/core/scheduler_preempt_test.go
Normal file
@ -0,0 +1,356 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// M3b 验收测试:四级优先级 + 严格大于抢占 + 现场保存/恢复 + suspendStack。
|
||||||
|
//
|
||||||
|
// 设计依据 docs/zh/input-scheduler-design.md §11(P1–P4、R1、R5、D1T、Q3)。
|
||||||
|
//
|
||||||
|
// 测试手法:用一个「第一次调用阻塞到 ctx 取消、之后按脚本返回」的 provider,
|
||||||
|
// 让测试可以确定性地把运行任务停在 S_LLM 上,再注入中断观察让位与恢复。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// preemptProvider 第 1 次 Chat 阻塞直到 ctx 取消;第 2 次起返回脚本。
|
||||||
|
type preemptProvider struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
calls int
|
||||||
|
entered chan struct{}
|
||||||
|
enteredOn sync.Once
|
||||||
|
responses []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPreemptProvider(responses ...string) *preemptProvider {
|
||||||
|
return &preemptProvider{entered: make(chan struct{}), responses: responses}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *preemptProvider) Name() string { return "preempt" }
|
||||||
|
|
||||||
|
func (p *preemptProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.calls++
|
||||||
|
n := p.calls
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if n == 1 {
|
||||||
|
p.enteredOn.Do(func() { close(p.entered) })
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
i := n - 2
|
||||||
|
if i < len(p.responses) {
|
||||||
|
return &agentAPI.CompletionResponse{Content: p.responses[i]}, nil
|
||||||
|
}
|
||||||
|
return &agentAPI.CompletionResponse{Content: "done"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *preemptProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||||
|
return nil, context.Canceled
|
||||||
|
}
|
||||||
|
func (p *preemptProvider) MaxContextTokens() int { return 8192 }
|
||||||
|
|
||||||
|
func (p *preemptProvider) callCount() int {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return p.calls
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPreemptAgent(t *testing.T, sp agentAPI.Provider) *Agent {
|
||||||
|
t.Helper()
|
||||||
|
return New(AgentConfig{
|
||||||
|
ID: "preempt",
|
||||||
|
Provider: sp,
|
||||||
|
ProviderManager: agentAPI.NewProviderManager(),
|
||||||
|
IO: agentIO.NewIOManager(),
|
||||||
|
StageHost: NewStageHost(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueueQueued 入队一个**排队任务**(无级别)——对应 InjectText*/InjectInputSync*。
|
||||||
|
func enqueueQueued(t *testing.T, a *Agent, source, content string) (*Task, *agentIO.InputEvent) {
|
||||||
|
t.Helper()
|
||||||
|
evt, _ := textEvent(source, content)
|
||||||
|
task := newInputTask(evt)
|
||||||
|
if !a.sched.enqueue(task) {
|
||||||
|
t.Fatal("入队失败")
|
||||||
|
}
|
||||||
|
return task, evt
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueueInterrupt 登记一次**中断**(走 requestPreempt:能抢占则进 immediate 槽,
|
||||||
|
// 否则按级别进中断队列),返回被登记的任务。
|
||||||
|
func enqueueInterrupt(t *testing.T, a *Agent, level Level, source, content string) (*Task, *agentIO.InputEvent) {
|
||||||
|
t.Helper()
|
||||||
|
evt, _ := textEvent(source, content)
|
||||||
|
evt.Payload["interrupt"] = true
|
||||||
|
a.sched.requestPreempt(evt, level)
|
||||||
|
snap := a.DumpScheduler()
|
||||||
|
if snap.Immediate != nil && snap.Immediate.Event == evt {
|
||||||
|
return snap.Immediate, evt
|
||||||
|
}
|
||||||
|
q := snap.InterruptQueues[clampPluginLevel(level)]
|
||||||
|
for i := len(q) - 1; i >= 0; i-- {
|
||||||
|
if q[i].Event == evt {
|
||||||
|
return q[i], evt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, evt
|
||||||
|
}
|
||||||
|
|
||||||
|
// P1 + R1 + R5 + D1=A:高优先级抢占 → 挂起在 S_LLM → 中断任务带只读前缀 →
|
||||||
|
// 恢复后从 S_LLM 重发,且原任务的 msgs 未被改动。
|
||||||
|
func TestPreempt_HigherPreemptsAndResumes(t *testing.T) {
|
||||||
|
sp := newPreemptProvider("intr-done", "low-done")
|
||||||
|
a := newPreemptAgent(t, sp)
|
||||||
|
|
||||||
|
lowTask, _ := enqueueQueued(t, a, "qq", "低优先级任务")
|
||||||
|
lt, _, kind := a.sched.nextRef()
|
||||||
|
if kind != nextReady || lt != lowTask {
|
||||||
|
t.Fatalf("应取到低优先级任务,kind=%v", kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { a.executeNewTask(lt); close(done) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-sp.entered:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("provider 第 1 次调用未发生")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注入 L4 中断(cli)
|
||||||
|
intrEvt, _ := textEvent("cli", "紧急打断")
|
||||||
|
intrEvt.Payload["interrupt"] = true
|
||||||
|
if !a.sched.requestKernelPreempt(intrEvt) {
|
||||||
|
t.Fatal("L4 应请求抢占并返回 true(应取消 LLM)")
|
||||||
|
}
|
||||||
|
a.cancelCurrentLLM()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("低优先级任务未在取消后挂起")
|
||||||
|
}
|
||||||
|
|
||||||
|
snap := a.DumpScheduler()
|
||||||
|
if snap.Running != nil {
|
||||||
|
t.Fatal("挂起后不应还有 running")
|
||||||
|
}
|
||||||
|
if len(snap.SuspendStack) != 1 {
|
||||||
|
t.Fatalf("suspendStack=%d,期望 1", len(snap.SuspendStack))
|
||||||
|
}
|
||||||
|
if len(snap.PendingInterrupts) != 1 {
|
||||||
|
t.Fatalf("pendingInterrupts=%d,期望 1", len(snap.PendingInterrupts))
|
||||||
|
}
|
||||||
|
sf := snap.SuspendStack[0].Frame
|
||||||
|
if sf.Terminal != terminalSuspended {
|
||||||
|
t.Fatalf("挂起任务终态=%v,期望 terminalSuspended", sf.Terminal)
|
||||||
|
}
|
||||||
|
if sf.Step != StepLLM {
|
||||||
|
t.Fatalf("应在 StepLLM 安全点挂起,实际 step=%v", sf.Step)
|
||||||
|
}
|
||||||
|
msgsBefore := len(sf.Msgs)
|
||||||
|
|
||||||
|
// R5:第一次 LLM 调用被丢弃,未产生新消息。
|
||||||
|
if sp.callCount() != 1 {
|
||||||
|
t.Fatalf("挂起前 LLM 调用=%d,期望 1(不完整请求被丢弃)", sp.callCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Q3:三集合统一比较 → 下一轮取中断(L4 > L1)。
|
||||||
|
it, _, k := a.sched.nextRef()
|
||||||
|
if k != nextImmediate || it.Level != LevelCritical {
|
||||||
|
t.Fatalf("应取到 pending 中断,kind=%v level=%v", k, it.Level)
|
||||||
|
}
|
||||||
|
// D1=B:中断任务在**上一个任务之前的完整状态**上开始运行,不继承本任务的现场。
|
||||||
|
// 因此它能看到的唯一输入就是它自己携带的内容。
|
||||||
|
if it.Event == nil {
|
||||||
|
t.Fatal("中断任务必须携带自己的输入事件")
|
||||||
|
}
|
||||||
|
if got, _ := it.Event.Payload["content"].(string); got != "紧急打断" {
|
||||||
|
t.Fatalf("中断任务输入=%q,期望 紧急打断", got)
|
||||||
|
}
|
||||||
|
a.executeNewTask(it)
|
||||||
|
if len(a.DumpScheduler().PendingInterrupts) != 0 {
|
||||||
|
t.Fatal("中断任务执行后 pendingInterrupts 应清空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// R1:恢复被抢占任务。基础前缀被重建到「中断任务之上」(含中断已提交的上下文),
|
||||||
|
// 本任务自己的现场接回其后,然后从 S_LLM 重发。
|
||||||
|
rt, rf, k2 := a.sched.nextRef()
|
||||||
|
if k2 != nextSuspended || rt != lowTask {
|
||||||
|
t.Fatalf("应恢复被抢占任务,kind=%v", k2)
|
||||||
|
}
|
||||||
|
if rf.Step != StepLLM {
|
||||||
|
t.Fatalf("恢复游标=%v,期望 StepLLM", rf.Step)
|
||||||
|
}
|
||||||
|
a.resumeTask(rt, rf)
|
||||||
|
|
||||||
|
// 「加载回中断之上」的判据:恢复后的消息序列里必须出现中断任务的上下文。
|
||||||
|
if !msgsContain(rf.Msgs, "紧急打断") {
|
||||||
|
t.Fatal("恢复后的任务应看见中断任务的上下文(现场未加载回中断之上)")
|
||||||
|
}
|
||||||
|
// 本任务自己的现场(工具轮产物)仍然在。
|
||||||
|
if len(rf.Msgs) < msgsBefore {
|
||||||
|
t.Fatalf("恢复后消息数=%d,不应少于被抢占前的 %d", len(rf.Msgs), msgsBefore)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sp.callCount() != 3 {
|
||||||
|
t.Fatalf("LLM 总调用=%d,期望 3(丢弃 1 + 中断 1 + 恢复 1)", sp.callCount())
|
||||||
|
}
|
||||||
|
snap = a.DumpScheduler()
|
||||||
|
if len(snap.SuspendStack) != 0 || snap.Running != nil {
|
||||||
|
t.Fatalf("全部结束后应无挂起与运行任务:%+v", snap)
|
||||||
|
}
|
||||||
|
if snap.Stats.Executed != 2 {
|
||||||
|
t.Fatalf("Executed=%d,期望 2(中断任务 + 被抢占任务)", snap.Stats.Executed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// P2/P3:同级与更低级都不得抢占,请求进中断队列。
|
||||||
|
//
|
||||||
|
// 注意:能比“同级/更低不得抢占”的只可能是**中断之间**——排队任务无级别,
|
||||||
|
// 任何中断都能打断它(这是模型的规定,不是漏洞)。
|
||||||
|
func TestPreempt_LowerOrEqualDoesNotPreempt(t *testing.T) {
|
||||||
|
sp := newPreemptProvider("low-done", "intr-done")
|
||||||
|
a := newPreemptAgent(t, sp)
|
||||||
|
|
||||||
|
lowTask, _ := enqueueInterrupt(t, a, LevelInteractive, "cli", "运行中的 L3")
|
||||||
|
if _, _, kind := a.sched.nextRef(); kind != nextInterrupt {
|
||||||
|
t.Fatal("应取到运行中的 L3 中断")
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { a.executeNewTask(lowTask); close(done) }()
|
||||||
|
select {
|
||||||
|
case <-sp.entered:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("provider 未被调用")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同级 L3
|
||||||
|
e1, _ := textEvent("webui", "同级打断")
|
||||||
|
if a.sched.requestPreempt(e1, LevelInteractive) {
|
||||||
|
t.Fatal("同级不得抢占")
|
||||||
|
}
|
||||||
|
// 更低级 L1
|
||||||
|
e2, _ := textEvent("system", "低优先级打断")
|
||||||
|
if a.sched.requestPreempt(e2, LevelBackground) {
|
||||||
|
t.Fatal("更低级不得抢占")
|
||||||
|
}
|
||||||
|
if a.sched.preemptGrantedFor() {
|
||||||
|
t.Fatal("未 arm 让位信号,preemptGrantedFor 应为 false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行任务没有被取消,仍在等它自己的 ctx;用取消让它收尾,便于清理。
|
||||||
|
a.cancelCurrentLLM()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("运行任务未结束")
|
||||||
|
}
|
||||||
|
if len(a.DumpScheduler().PendingInterrupts) != 2 {
|
||||||
|
t.Fatalf("两条未抢占中断都应保留在中断队列,实际 %d",
|
||||||
|
len(a.DumpScheduler().PendingInterrupts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// D1T:suspendStack 满时不再下潜(canSuspend=false),让位信号也不会 arm。
|
||||||
|
func TestPreempt_DepthCapBlocksSuspension(t *testing.T) {
|
||||||
|
a := newPreemptAgent(t, newPreemptProvider())
|
||||||
|
|
||||||
|
if a.sched.maxInterruptFrames != 4 {
|
||||||
|
t.Fatalf("默认栈深上界=%d,期望 4(= 中断级数,结构推论)", a.sched.maxInterruptFrames)
|
||||||
|
}
|
||||||
|
frame := func() *TaskFrame { return a.newTaskFrame("x", a.stageCtxFromInput("x", "", "")) }
|
||||||
|
for i := 0; i < a.sched.maxInterruptFrames; i++ {
|
||||||
|
a.sched.suspend(&Task{ID: uint64(i + 1), Class: TaskInterrupt, Level: LevelBackground}, frame())
|
||||||
|
}
|
||||||
|
if a.sched.canSuspend() {
|
||||||
|
t.Fatal("深度已达上限,canSuspend 应为 false")
|
||||||
|
}
|
||||||
|
// 超限兜底:仍保留帧(不丢副作用记录),但计数 Rejected。
|
||||||
|
before := a.DumpScheduler().Stats.Rejected
|
||||||
|
a.sched.suspend(&Task{ID: 99, Class: TaskInterrupt, Level: LevelBackground}, frame())
|
||||||
|
if a.DumpScheduler().Stats.Rejected != before+1 {
|
||||||
|
t.Fatal("超限挂起必须计数 Rejected")
|
||||||
|
}
|
||||||
|
if len(a.DumpScheduler().SuspendStack) != a.sched.maxInterruptFrames+1 {
|
||||||
|
t.Fatal("兜底路径必须保留帧而不是丢弃")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中断不丢:空闲时请求抢占 → 不 arm 信号,但请求进 pendingInterrupts 并被选出。
|
||||||
|
func TestPreempt_IdleInterruptIsQueuedNotLost(t *testing.T) {
|
||||||
|
a := newPreemptAgent(t, &scriptProvider{script: []*agentAPI.CompletionResponse{{Content: "已处理中断"}}})
|
||||||
|
|
||||||
|
evt, respCh := textEvent("qq", "空闲时的中断")
|
||||||
|
if a.sched.requestPreempt(evt, LevelMessage) {
|
||||||
|
t.Fatal("空闲时不应请求取消 LLM(没有运行任务)")
|
||||||
|
}
|
||||||
|
if len(a.DumpScheduler().PendingInterrupts) != 1 {
|
||||||
|
t.Fatal("空闲时的中断必须进中断队列(不能丢)")
|
||||||
|
}
|
||||||
|
task, _, kind := a.sched.nextRef()
|
||||||
|
if kind != nextInterrupt || task.Level != LevelMessage {
|
||||||
|
t.Fatalf("应取到待处理中断,kind=%v", kind)
|
||||||
|
}
|
||||||
|
a.executeNewTask(task)
|
||||||
|
if len(respCh) != 1 {
|
||||||
|
t.Fatal("中断任务应完成并回执")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// msgsContain 报告消息序列里是否出现过某段文本(用于“现场是否合回”的断言)。
|
||||||
|
func msgsContain(msgs []agentAPI.Message, sub string) bool {
|
||||||
|
for _, m := range msgs {
|
||||||
|
if strings.Contains(m.Content, sub) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复时的重建必须把 prepare 段对尾部消息的两处改写补回:
|
||||||
|
// 中断标记(IsInterrupt)与多模态块(InputBlocks)。
|
||||||
|
func TestPreempt_ResumeRebaseRestoresTailDecorations(t *testing.T) {
|
||||||
|
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{{Content: "ok"}}}
|
||||||
|
a := newPreemptAgent(t, sp)
|
||||||
|
|
||||||
|
block := agentAPI.ContentBlock{Type: "text", Text: "图"}
|
||||||
|
f := a.newTaskFrame("打断文本", a.stageCtxFromInput("打断文本", "", ""))
|
||||||
|
f.IsInterrupt = true
|
||||||
|
f.InputBlocks = []agentAPI.ContentBlock{block}
|
||||||
|
// 模拟 prepare 后的形状:基础前缀 + 一段“本任务自己的现场”
|
||||||
|
f.Msgs = []agentAPI.Message{
|
||||||
|
{Role: "system", Content: "S"},
|
||||||
|
{Role: "user", Content: "[中断消息] 打断文本"},
|
||||||
|
{Role: "assistant", Content: "进行中"},
|
||||||
|
}
|
||||||
|
f.PrefixLen = 2
|
||||||
|
|
||||||
|
a.rebaseFramePrefix(f)
|
||||||
|
|
||||||
|
if f.PrefixLen <= 0 || f.PrefixLen >= len(f.Msgs) {
|
||||||
|
t.Fatalf("重建后 PrefixLen=%d,消息数=%d,前缀应短于总数", f.PrefixLen, len(f.Msgs))
|
||||||
|
}
|
||||||
|
// 尾部现场(assistant 进行中)必须还在最后。
|
||||||
|
if last := f.Msgs[len(f.Msgs)-1]; last.Content != "进行中" {
|
||||||
|
t.Fatalf("本任务现场应接回最后,实际 %+v", last)
|
||||||
|
}
|
||||||
|
prefixLast := f.Msgs[f.PrefixLen-1]
|
||||||
|
if prefixLast.Role != "system" || !strings.HasPrefix(prefixLast.Content, "[中断消息]") {
|
||||||
|
t.Fatalf("中断标记未补回:%+v", prefixLast)
|
||||||
|
}
|
||||||
|
if len(prefixLast.Blocks) != 1 || prefixLast.Blocks[0].Text != "图" {
|
||||||
|
t.Fatalf("多模态块未补回:%+v", prefixLast.Blocks)
|
||||||
|
}
|
||||||
|
}
|
||||||
101
internal/agent/core/scheduler_regression_test.go
Normal file
101
internal/agent/core/scheduler_regression_test.go
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// 回归判据:M3 实现与设计稿 §5.1/§4.3 的两处偏离。
|
||||||
|
//
|
||||||
|
// 这两条是**先写判据、确认失败、再修**的(修完保留为回归测试)。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cancelAwareProvider 第一次调用进入后阻塞,直到 ctx 取消;记录是否被取消。
|
||||||
|
type cancelAwareProvider struct {
|
||||||
|
once sync.Once
|
||||||
|
entered chan struct{}
|
||||||
|
canceled atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCancelAwareProvider() *cancelAwareProvider {
|
||||||
|
return &cancelAwareProvider{entered: make(chan struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *cancelAwareProvider) Name() string { return "cancel-aware" }
|
||||||
|
func (p *cancelAwareProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||||
|
p.once.Do(func() { close(p.entered) })
|
||||||
|
<-ctx.Done()
|
||||||
|
p.canceled.Store(true)
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
func (p *cancelAwareProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||||
|
return nil, context.Canceled
|
||||||
|
}
|
||||||
|
func (p *cancelAwareProvider) MaxContextTokens() int { return 8192 }
|
||||||
|
|
||||||
|
// 设计 §5.1 ③:interruptLoop 定级/决策后必须**唤醒调度器**。
|
||||||
|
// 现状:中断只被放进 pendingInterrupts,而调度器空闲时阻塞在 select(只看
|
||||||
|
// InputChan/selfInputCh/ctx.Done)——没有任何东西会把它叫醒。
|
||||||
|
func TestGap_IdleInterruptIsProcessed(t *testing.T) {
|
||||||
|
sp := &countingProvider{}
|
||||||
|
a := New(AgentConfig{
|
||||||
|
ID: "idle-intr",
|
||||||
|
Provider: sp,
|
||||||
|
ProviderManager: agentAPI.NewProviderManager(),
|
||||||
|
IO: agentIO.NewIOManager(),
|
||||||
|
StageHost: NewStageHost(),
|
||||||
|
})
|
||||||
|
a.Start()
|
||||||
|
defer a.Stop()
|
||||||
|
|
||||||
|
// 完全空闲时投递一条中断(模拟定时器通知/插件提醒)。
|
||||||
|
a.io.InjectInterruptText("qq", "cli", "空闲时的通知")
|
||||||
|
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for sp.n.Load() == 0 && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if sp.n.Load() == 0 {
|
||||||
|
snap := a.DumpScheduler()
|
||||||
|
t.Fatalf("空闲时到达的中断未被处理:pendingInterrupts=%d(调度器未被唤醒)",
|
||||||
|
len(snap.PendingInterrupts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设计 §4.3/§5.2:`_consolidation_` 是整任务临界区——抢占请求必须排队等它结束,
|
||||||
|
// 而不是取消它。现状:requestPreempt 不判临界区,interceptLoop 照常 cancelLLM,
|
||||||
|
// 于是正在流式的记忆整理被中断 → stepLLM 直接以 error 结束(整理丢一半)。
|
||||||
|
func TestGap_ConsolidationMustNotBeCancelled(t *testing.T) {
|
||||||
|
sp := newCancelAwareProvider()
|
||||||
|
a := New(AgentConfig{
|
||||||
|
ID: "consol",
|
||||||
|
Provider: sp,
|
||||||
|
ProviderManager: agentAPI.NewProviderManager(),
|
||||||
|
IO: agentIO.NewIOManager(),
|
||||||
|
StageHost: NewStageHost(),
|
||||||
|
})
|
||||||
|
a.Start()
|
||||||
|
defer a.Stop()
|
||||||
|
|
||||||
|
// 走自循环通道发起一次记忆整理。
|
||||||
|
a.selfInputCh <- selfInputMsg{text: "合并实体", channel: channelConsolidation}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-sp.entered:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("记忆整理未进入 LLM 调用")
|
||||||
|
}
|
||||||
|
|
||||||
|
// L4 中断到达。
|
||||||
|
a.io.InjectInterruptText("cli", "cli", "L4 打断")
|
||||||
|
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
if sp.canceled.Load() {
|
||||||
|
t.Fatal("记忆整理是临界区,其 LLM 不该被取消(设计 §4.3/§5.2)")
|
||||||
|
}
|
||||||
|
}
|
||||||
192
internal/agent/core/scheduler_stack_test.go
Normal file
192
internal/agent/core/scheduler_stack_test.go
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// 中断栈(嵌套抢占)验收测试。
|
||||||
|
//
|
||||||
|
// 用户明确:存在**中断被中断**的场景,所以被打断的现场要压进**中断栈**。
|
||||||
|
// 因此恢复纪律是**严格 LIFO(只比栈顶)**,而不是“全栈按优先级挑最优”。
|
||||||
|
//
|
||||||
|
// 为什么这个区别成立:抢占判据是 adopted.level > effectiveLevel(running),
|
||||||
|
// 所以嵌套时栈自底向上的**基础级**天然递增;但饥饿防护的“有效级提升”会让
|
||||||
|
// 栈内某个更老的任务有效级超过栈顶,此时“只比栈顶”才保证嵌套语义不被破坏。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// nestingProvider 第 1、2 次调用阻塞到 ctx 取消;第 3 次起按脚本返回。
|
||||||
|
// 用序号精确对应 A(L1)→ B(L2)→ C(L3)→ 恢复 B → 恢复 A 的调用顺序。
|
||||||
|
type nestingProvider struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
calls int
|
||||||
|
entered chan int
|
||||||
|
script []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newNestingProvider(script ...string) *nestingProvider {
|
||||||
|
return &nestingProvider{entered: make(chan int, 16), script: script}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *nestingProvider) Name() string { return "nesting" }
|
||||||
|
func (p *nestingProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.calls++
|
||||||
|
n := p.calls
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
p.entered <- n
|
||||||
|
if n <= 2 {
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
i := n - 3
|
||||||
|
if i < len(p.script) {
|
||||||
|
return &agentAPI.CompletionResponse{Content: p.script[i]}, nil
|
||||||
|
}
|
||||||
|
return &agentAPI.CompletionResponse{Content: "?"}, nil
|
||||||
|
}
|
||||||
|
func (p *nestingProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||||
|
return nil, context.Canceled
|
||||||
|
}
|
||||||
|
func (p *nestingProvider) MaxContextTokens() int { return 8192 }
|
||||||
|
|
||||||
|
func awaitEnter(t *testing.T, ch chan int, want int) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case got := <-ch:
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("LLM 进入序号=%d,期望 %d", got, want)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatalf("等第 %d 次 LLM 调用超时", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中断被中断:A(L1) → B(L2) → C(L3),恢复必须按 LIFO(B 先,A 后)。
|
||||||
|
func TestStack_NestedPreemptionResumesLIFO(t *testing.T) {
|
||||||
|
sp := newNestingProvider("c-done", "b-done", "a-done")
|
||||||
|
a := newPreemptAgent(t, sp)
|
||||||
|
|
||||||
|
// A(L1)开始运行
|
||||||
|
if _, _ = enqueueQueued(t, a, "qq", "任务A"); true {
|
||||||
|
}
|
||||||
|
at, _, _ := a.sched.nextRef()
|
||||||
|
doneA := make(chan struct{})
|
||||||
|
go func() { a.executeNewTask(at); close(doneA) }()
|
||||||
|
awaitEnter(t, sp.entered, 1)
|
||||||
|
|
||||||
|
// B(L2)抢占 A
|
||||||
|
bEvt, _ := textEvent("qq", "任务B")
|
||||||
|
if !a.sched.requestPreempt(bEvt, LevelMessage) {
|
||||||
|
t.Fatal("B(L2) 应抢占 A(L1)")
|
||||||
|
}
|
||||||
|
a.cancelCurrentLLM()
|
||||||
|
<-doneA
|
||||||
|
if n := len(a.DumpScheduler().SuspendStack); n != 1 {
|
||||||
|
t.Fatalf("第一次抢占后栈深=%d,期望 1", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// B 开始运行
|
||||||
|
bt, _, k := a.sched.nextRef()
|
||||||
|
if k != nextImmediate || bt.Level != LevelMessage {
|
||||||
|
t.Fatalf("应取到 B(pending),kind=%v level=%v", k, bt.Level)
|
||||||
|
}
|
||||||
|
doneB := make(chan struct{})
|
||||||
|
go func() { a.executeNewTask(bt); close(doneB) }()
|
||||||
|
awaitEnter(t, sp.entered, 2)
|
||||||
|
|
||||||
|
// C(L3)抢占 B —— 这就是“中断被中断”
|
||||||
|
cEvt, _ := textEvent("cli", "任务C")
|
||||||
|
if !a.sched.requestPreempt(cEvt, LevelInteractive) {
|
||||||
|
t.Fatal("C(L3) 应抢占 B(L2)")
|
||||||
|
}
|
||||||
|
a.cancelCurrentLLM()
|
||||||
|
<-doneB
|
||||||
|
|
||||||
|
snap := a.DumpScheduler()
|
||||||
|
if len(snap.SuspendStack) != 2 {
|
||||||
|
t.Fatalf("嵌套后栈深=%d,期望 2", len(snap.SuspendStack))
|
||||||
|
}
|
||||||
|
if snap.SuspendStack[0].Task.Class != TaskQueued {
|
||||||
|
t.Fatalf("栈底应为排队任务 A(无级别),实际 %v", snap.SuspendStack[0].Task.Class)
|
||||||
|
}
|
||||||
|
if snap.SuspendStack[1].Task.Level != LevelMessage {
|
||||||
|
t.Fatalf("栈顶应为 B(L2),实际 %v", snap.SuspendStack[1].Task.Level)
|
||||||
|
}
|
||||||
|
|
||||||
|
// C 运行完毕(第三次调用,不阻塞)
|
||||||
|
ct, _, k := a.sched.nextRef()
|
||||||
|
if k != nextImmediate || ct.Level != LevelInteractive {
|
||||||
|
t.Fatalf("应取到 C,kind=%v level=%v", k, ct.Level)
|
||||||
|
}
|
||||||
|
a.executeNewTask(ct)
|
||||||
|
|
||||||
|
// LIFO:先恢复栈顶 B,再恢复 A
|
||||||
|
rt, rf, k := a.sched.nextRef()
|
||||||
|
if k != nextSuspended {
|
||||||
|
t.Fatalf("应恢复栈顶,kind=%v", k)
|
||||||
|
}
|
||||||
|
if rt.Level != LevelMessage {
|
||||||
|
t.Fatalf("应先恢复栈顶 B(L2),实际 %v", rt.Level)
|
||||||
|
}
|
||||||
|
a.resumeTask(rt, rf)
|
||||||
|
|
||||||
|
rt2, rf2, k2 := a.sched.nextRef()
|
||||||
|
if k2 != nextSuspended {
|
||||||
|
t.Fatalf("应继续恢复 A,kind=%v", k2)
|
||||||
|
}
|
||||||
|
if rt2.Class != TaskQueued {
|
||||||
|
t.Fatalf("最后应恢复排队的 A(无级别),实际 %v", rt2.Class)
|
||||||
|
}
|
||||||
|
a.resumeTask(rt2, rf2)
|
||||||
|
|
||||||
|
if n := len(a.DumpScheduler().SuspendStack); n != 0 {
|
||||||
|
t.Fatalf("全部恢复后栈应清空,实际 %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只比栈顶:栈内更老的任务即使(因有效级提升)优先级更高,也不得越过栈顶。
|
||||||
|
func TestStack_TopOnlyWinsOverHigherPrioritySuspended(t *testing.T) {
|
||||||
|
a := newPreemptAgent(t, &scriptProvider{})
|
||||||
|
|
||||||
|
// 人为构造“A(L3) 在栈底、B(L2) 在栈顶”。真实抢占不会产生这种顺序
|
||||||
|
// (栈自底向上基础级递增),这里专门用来区分两种实现:
|
||||||
|
// · 只比栈顶 → 取 B
|
||||||
|
// · 全栈扫最优 → 取 A(L3 > L2)
|
||||||
|
a.sched.suspend(&Task{ID: 1, Class: TaskInterrupt, Level: LevelInteractive, EnqueuedAt: time.Now()},
|
||||||
|
a.newTaskFrame("A", a.stageCtxFromInput("A", "", "")))
|
||||||
|
a.sched.suspend(&Task{ID: 2, Class: TaskInterrupt, Level: LevelMessage, EnqueuedAt: time.Now()},
|
||||||
|
a.newTaskFrame("B", a.stageCtxFromInput("B", "", "")))
|
||||||
|
|
||||||
|
rt, _, k := a.sched.nextRef()
|
||||||
|
if k != nextSuspended {
|
||||||
|
t.Fatalf("kind=%v,期望 nextSuspended", k)
|
||||||
|
}
|
||||||
|
if rt.ID != 2 {
|
||||||
|
t.Fatalf("应取栈顶 B(ID=2),实际 ID=%d —— 说明在做全栈优先级扫描而非栈语义", rt.ID)
|
||||||
|
}
|
||||||
|
if n := len(a.DumpScheduler().SuspendStack); n != 1 {
|
||||||
|
t.Fatalf("取出栈顶后栈深=%d,期望 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 深度上限对嵌套同样成立:到顶后新的抢占请求不再下潜。
|
||||||
|
func TestStack_DepthCapDuringNesting(t *testing.T) {
|
||||||
|
a := newPreemptAgent(t, &scriptProvider{})
|
||||||
|
frame := func() *TaskFrame { return a.newTaskFrame("x", a.stageCtxFromInput("x", "", "")) }
|
||||||
|
for i := 0; i < a.sched.maxInterruptFrames; i++ {
|
||||||
|
a.sched.suspend(&Task{ID: uint64(i + 1), Class: TaskInterrupt, Level: Level(i + 1)}, frame())
|
||||||
|
}
|
||||||
|
if a.sched.canSuspend() {
|
||||||
|
t.Fatal("栈已满,canSuspend 应为 false")
|
||||||
|
}
|
||||||
|
if n := len(a.DumpScheduler().SuspendStack); n != a.sched.maxInterruptFrames {
|
||||||
|
t.Fatalf("栈深=%d,期望上界 %d", n, a.sched.maxInterruptFrames)
|
||||||
|
}
|
||||||
|
}
|
||||||
124
internal/agent/core/scheduler_starvation_test.go
Normal file
124
internal/agent/core/scheduler_starvation_test.go
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// M5 验收测试:饥饿防护(抢占计数提升有效级 + 抢占冷却)。
|
||||||
|
//
|
||||||
|
// 设计依据 docs/zh/input-scheduler-design.md §9、§11.5(G1/G2)。
|
||||||
|
//
|
||||||
|
// 为什么需要:固定四级 + 「严格大于才抢占」下,一条 L4 流可以反复打断同一个
|
||||||
|
// L1 任务,使它永不完结。提升被抢占者的**有效**优先级,让它在竞争排队时
|
||||||
|
// 逐步追上;封顶 L4,因此它永远抢不过真正的紧急输入(紧急输入本身不被抢占)。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStarvation_EffectiveLevelPromotion(t *testing.T) {
|
||||||
|
base := &Task{Class: TaskInterrupt, Level: LevelBackground}
|
||||||
|
if got := effectiveLevel(base); got != LevelBackground {
|
||||||
|
t.Fatalf("未抢占时有效级=%v,期望 L1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
base.PreemptCount = 1
|
||||||
|
if got := effectiveLevel(base); got != LevelMessage {
|
||||||
|
t.Fatalf("被抢占 1 次后有效级=%v,期望 L2", got)
|
||||||
|
}
|
||||||
|
base.PreemptCount = 2
|
||||||
|
if got := effectiveLevel(base); got != LevelInteractive {
|
||||||
|
t.Fatalf("被抢占 2 次后有效级=%v,期望 L3", got)
|
||||||
|
}
|
||||||
|
base.PreemptCount = 99
|
||||||
|
if got := effectiveLevel(base); got != LevelInteractive {
|
||||||
|
t.Fatalf("提升应封顶在 +2 档,实际 %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 封顶 L4:L3 任务被多次抢占也不会超过紧急级。
|
||||||
|
high := &Task{Class: TaskInterrupt, Level: LevelInteractive, PreemptCount: 99}
|
||||||
|
if got := effectiveLevel(high); got != LevelCritical {
|
||||||
|
t.Fatalf("L3 提升后应封顶为 L4,实际 %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStarvation_CooldownBlocksImmediateRepreempt(t *testing.T) {
|
||||||
|
a := newPreemptAgent(t, newPreemptProvider())
|
||||||
|
|
||||||
|
low := &Task{ID: 1, Class: TaskInterrupt, Level: LevelBackground, EnqueuedAt: time.Now()}
|
||||||
|
a.sched.immediate = low
|
||||||
|
a.sched.nextRef() // running = low
|
||||||
|
|
||||||
|
e1, _ := textEvent("qq", "第一次打断")
|
||||||
|
if !a.sched.requestPreempt(e1, LevelMessage) {
|
||||||
|
t.Fatal("L2 应能抢占 L1(首次)")
|
||||||
|
}
|
||||||
|
a.sched.suspend(low, a.newTaskFrame("x", a.stageCtxFromInput("x", "", "")))
|
||||||
|
if low.PreemptCount != 1 {
|
||||||
|
t.Fatalf("PreemptCount=%d,期望 1", low.PreemptCount)
|
||||||
|
}
|
||||||
|
if low.LastPreemptAt.IsZero() {
|
||||||
|
t.Fatal("挂起必须记录 LastPreemptAt(冷却起点)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 冷却期内:即使 L4 也不得再抢占。
|
||||||
|
a.sched.mu.Lock()
|
||||||
|
a.sched.running = low
|
||||||
|
a.sched.mu.Unlock()
|
||||||
|
|
||||||
|
e2, _ := textEvent("cli", "冷却期内的紧急打断")
|
||||||
|
if a.sched.requestKernelPreempt(e2) {
|
||||||
|
t.Fatal("抢占冷却期内不得再抢占")
|
||||||
|
}
|
||||||
|
if a.sched.preemptGrantedFor() {
|
||||||
|
t.Fatal("冷却期内不得 arm 让位信号")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStarvation_PromotionBlocksSameLevelPreempt(t *testing.T) {
|
||||||
|
a := newPreemptAgent(t, newPreemptProvider())
|
||||||
|
|
||||||
|
low := &Task{ID: 1, Class: TaskInterrupt, Level: LevelBackground, EnqueuedAt: time.Now()}
|
||||||
|
a.sched.immediate = low
|
||||||
|
a.sched.nextRef()
|
||||||
|
// 模拟「已被抢占过一次」:有效级 = L2。
|
||||||
|
low.PreemptCount = 1
|
||||||
|
low.LastPreemptAt = time.Now().Add(-time.Hour) // 冷却已过
|
||||||
|
|
||||||
|
e1, _ := textEvent("qq", "同级打断")
|
||||||
|
if a.sched.requestPreempt(e1, LevelMessage) {
|
||||||
|
t.Fatal("有效级 L2 时,L2 中断不得抢占(严格大于才抢占)")
|
||||||
|
}
|
||||||
|
|
||||||
|
e2, _ := textEvent("cli", "更高级打断")
|
||||||
|
if !a.sched.requestPreempt(e2, LevelInteractive) {
|
||||||
|
t.Fatal("L3 应能抢占有效级 L2")
|
||||||
|
}
|
||||||
|
if !a.sched.preemptGrantedFor() {
|
||||||
|
t.Fatal("L3 > 有效 L2,应已 arm")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提升必须真的进入抢占判据,而不只是一个数学性质:
|
||||||
|
// 被抢占过一次的 L1 中断(有效 L2)应当顶住同级 L2 流的再次抢占。
|
||||||
|
func TestStarvation_PromotionIsVisibleInSelection(t *testing.T) {
|
||||||
|
a := newPreemptAgent(t, newPreemptProvider())
|
||||||
|
|
||||||
|
// 栈里放一个「被抢占过一次的 L1」:有效级 L2。
|
||||||
|
a.sched.suspend(&Task{ID: 1, Class: TaskInterrupt, Level: LevelBackground, PreemptCount: 1},
|
||||||
|
a.newTaskFrame("A", a.stageCtxFromInput("A", "", "")))
|
||||||
|
|
||||||
|
// 队列里来一个 L2:有效级持平(2 vs 2)→ 不得越过栈顶。
|
||||||
|
evt, _ := textEvent("qq", "L2 中断")
|
||||||
|
a.sched.registerInterrupt(newInterruptTask(evt, LevelMessage))
|
||||||
|
if _, _, kind := a.sched.nextRef(); kind != nextSuspended {
|
||||||
|
t.Fatalf("有效级持平应恢复栈顶,kind=%v", kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 队列里来一个 L3:严格大于 → 队头优先。
|
||||||
|
// PreemptCount 从 0 起:suspend 内部会 +1 → 有效级 L2(正好用来卡 L2 持平)。
|
||||||
|
a.sched.suspend(&Task{ID: 2, Class: TaskInterrupt, Level: LevelBackground},
|
||||||
|
a.newTaskFrame("B", a.stageCtxFromInput("B", "", "")))
|
||||||
|
evt2, _ := textEvent("cli", "L3 中断")
|
||||||
|
a.sched.registerInterrupt(newInterruptTask(evt2, LevelInteractive))
|
||||||
|
if _, _, kind := a.sched.nextRef(); kind != nextInterrupt {
|
||||||
|
t.Fatalf("L3 > 有效 L2 应取中断队列,kind=%v", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
351
internal/agent/core/scheduler_stress_test.go
Normal file
351
internal/agent/core/scheduler_stress_test.go
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// 优先级压力测试:**各级中断混合打入 + 排队输入**,全部经真实调度 loop 执行。
|
||||||
|
//
|
||||||
|
// 形状(用户指定):100 条中断(L1/L2/L3/L4 各 25,混合打入)+ 100 条排队输入。
|
||||||
|
//
|
||||||
|
// 为什么需要「等待合适的受害者再注入」:调度器是单线程的,同一时刻只有**一个**
|
||||||
|
// 运行任务。如果闭着眼睛猛灌,绝大多数中断会落在「没有受害者」或「受害者级别
|
||||||
|
// 不够」的时刻,于是全部退化成排队——压力测试就只压到了队列,没有压到抢占。
|
||||||
|
// 因此每条中断都等到「运行中的任务按规则**应当**被它打断」时再注入:
|
||||||
|
// - 受害者是排队任务(无级别)→ 任何中断都该抢占它;
|
||||||
|
// - 受害者是中断 Li → 只有 Lj > Li 才该抢占它。
|
||||||
|
//
|
||||||
|
// 同时验证分级计数:各级登记了多少、各级真正抢断了多少次(只看总数会掩盖
|
||||||
|
// 「总数一样但级别分布完全不同」这种情况)。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||||
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fixedDelayProvider 返回固定内容,并在被取消时立刻返回 ctx.Err()。
|
||||||
|
//
|
||||||
|
// 延迟是必需的:任务必须先「在跑」才谈得上被打断;取消感知也是必需的,
|
||||||
|
// 否则抢占只能等它自然结束,测不到挂起/恢复。
|
||||||
|
type fixedDelayProvider struct {
|
||||||
|
n atomic.Int64
|
||||||
|
delay time.Duration
|
||||||
|
cancelled atomic.Int64 // 被 ctx 取消(即“流式段被抢占打断”)的次数
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *fixedDelayProvider) Name() string { return "fixed-delay" }
|
||||||
|
|
||||||
|
func (p *fixedDelayProvider) Chat(ctx context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||||
|
select {
|
||||||
|
case <-time.After(p.delay):
|
||||||
|
case <-ctx.Done():
|
||||||
|
p.cancelled.Add(1)
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
p.n.Add(1)
|
||||||
|
return &agentAPI.CompletionResponse{Content: "fixed-reply"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *fixedDelayProvider) ChatStream(context.Context, *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||||
|
return nil, errors.New("fixed-delay provider: 非流式")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *fixedDelayProvider) MaxContextTokens() int { return 8192 }
|
||||||
|
|
||||||
|
// waitForVictim 等到「适合被 lv 打断的受害者」正在运行,**且没有别的待处理中断**。
|
||||||
|
//
|
||||||
|
// 后半个条件很重要:若有待处理中断,本次注入只会进队列(中断队列先于排队任务
|
||||||
|
// 被消费),压力就落在队列上而不是抢占/挂起路径上。
|
||||||
|
// 返回 false 表示等到超时(调用方仍应注入,保持总量不变)。
|
||||||
|
func waitForVictim(a *Agent, lv Level, wait time.Duration) bool {
|
||||||
|
deadline := time.Now().Add(wait)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
snap := a.DumpScheduler()
|
||||||
|
if r := snap.Running; r != nil && len(snap.PendingInterrupts) == 0 {
|
||||||
|
if r.Class == TaskQueued {
|
||||||
|
return true // 排队任务:任何中断都该抢占
|
||||||
|
}
|
||||||
|
if r.Class == TaskInterrupt && lv > r.Level {
|
||||||
|
return true // 中断之间:严格更高级才该抢占
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStress_MixedLevelInterruptsPlusQueuedInputs(t *testing.T) {
|
||||||
|
const (
|
||||||
|
nQueued = 100
|
||||||
|
nInterrupts = 100
|
||||||
|
// 窗口要够宽:受害者必须先"在跑",cancel 才来得及把它打断成挂起。
|
||||||
|
// 太短(如 15ms)时任务常在让位信号生效前就自己跑完——抢占判为可行,
|
||||||
|
// 但不会有挂起发生,压力就测不到现场保存/恢复。
|
||||||
|
llmDelay = 40 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// L4 只有内核级来源能声明,所以注册一个内置(编译期工厂)插件名。
|
||||||
|
plugin.RegisterFactory("stress_builtin", func(string, map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
sp := &fixedDelayProvider{delay: llmDelay}
|
||||||
|
a := New(AgentConfig{
|
||||||
|
ID: "stress-levels",
|
||||||
|
Provider: sp,
|
||||||
|
ProviderManager: agentAPI.NewProviderManager(),
|
||||||
|
IO: agentIO.NewIOManager(),
|
||||||
|
StageHost: NewStageHost(),
|
||||||
|
PluginReg: plugin.NewRegistry(),
|
||||||
|
})
|
||||||
|
a.Start()
|
||||||
|
defer a.Stop()
|
||||||
|
|
||||||
|
// 第一波:100 条排队输入(无级别,FIFO)。
|
||||||
|
for i := 0; i < nQueued; i++ {
|
||||||
|
a.io.InjectInput("cli", "text", map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("queued-%d", i),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 给调度器一点时间真正开始跑排队任务,这样第一条中断就有受害者。
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
|
// 第二波:100 条中断,级别 L1→L2→L3→L4 轮转(各 25 条)。
|
||||||
|
// 每条都等到「该被它打断的受害者正在跑」时再注入。
|
||||||
|
levels := []Level{LevelBackground, LevelMessage, LevelInteractive, LevelCritical}
|
||||||
|
names := map[Level]string{
|
||||||
|
LevelBackground: "L1", LevelMessage: "L2", LevelInteractive: "L3", LevelCritical: "L4",
|
||||||
|
}
|
||||||
|
// L4 必须来自内核级插件来源;其余用普通来源(cli 是内置名,但级别只有 L1..L3 时无所谓)。
|
||||||
|
src := func(lv Level) string {
|
||||||
|
if lv == LevelCritical {
|
||||||
|
return "stress_builtin"
|
||||||
|
}
|
||||||
|
return "cli"
|
||||||
|
}
|
||||||
|
for i := 0; i < nInterrupts; i++ {
|
||||||
|
lv := levels[i%len(levels)]
|
||||||
|
waitForVictim(a, lv, 3*time.Second)
|
||||||
|
a.io.InjectInterruptTextOpts(src(lv), "cli", fmt.Sprintf("irq-%s-%d", names[lv], i),
|
||||||
|
agentIO.InjectOptions{Priority: names[lv]})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排空:200 个任务必须全部到达终态,且四容器全空。
|
||||||
|
snap := waitQuiescent(t, a, nQueued+nInterrupts, 90*time.Second)
|
||||||
|
|
||||||
|
// ---- 不丢不重 ----
|
||||||
|
if snap.Stats.Executed != nQueued+nInterrupts {
|
||||||
|
t.Fatalf("Executed=%d,期望 %d(每个任务恰好一个终态)",
|
||||||
|
snap.Stats.Executed, nQueued+nInterrupts)
|
||||||
|
}
|
||||||
|
if snap.Stats.Rejected != 0 {
|
||||||
|
t.Fatalf("容量充足却出现 Rejected=%d(背压/深度判定有误)", snap.Stats.Rejected)
|
||||||
|
}
|
||||||
|
wantPerLevel := uint64(nInterrupts / len(levels))
|
||||||
|
for _, lv := range levels {
|
||||||
|
if got := snap.Stats.InterruptsByLevel[lv]; got != wantPerLevel {
|
||||||
|
t.Fatalf("%s 登记数=%d,期望 %d", names[lv], got, wantPerLevel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 抢占确实发生在**每一级**上 ----
|
||||||
|
if snap.Stats.Suspended == 0 {
|
||||||
|
t.Fatalf("100 条中断没有造成任何抢占:%+v", snap.Stats)
|
||||||
|
}
|
||||||
|
for _, lv := range levels {
|
||||||
|
if got := snap.Stats.PreemptsByLevel[lv]; got == 0 {
|
||||||
|
t.Fatalf("%s 一次都没抢断成功(分级计数=%v)", names[lv], snap.Stats.PreemptsByLevel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 每一次“取消流式段”都必须换来一次挂起(取消→stepLLM 以 Canceled 收尾→安全点让位)。
|
||||||
|
// 反向不成立:挂起也可能发生在别的步骤边界上(那时 LLM 已经成功返回、来不及取消)。
|
||||||
|
if cancelled := uint64(sp.cancelled.Load()); snap.Stats.Suspended < cancelled {
|
||||||
|
t.Fatalf("被取消的 LLM 调用=%d 但有 %d 次挂起:有取消没换来挂起(现场丢了?)",
|
||||||
|
cancelled, snap.Stats.Suspended)
|
||||||
|
}
|
||||||
|
// 排空后挂起必须等于恢复——挂起来的任务都被接回去了。
|
||||||
|
if snap.Stats.Suspended != snap.Stats.Resumed {
|
||||||
|
t.Fatalf("Suspended=%d Resumed=%d,排空后必须相等(否则有现场丢了)",
|
||||||
|
snap.Stats.Suspended, snap.Stats.Resumed)
|
||||||
|
}
|
||||||
|
if snap.Stats.Suspended > nInterrupts {
|
||||||
|
t.Fatalf("挂起次数=%d 超过中断总数 %d(不该有任务被反复挂起这么多次)",
|
||||||
|
snap.Stats.Suspended, nInterrupts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- LLM 调用次数:每个任务至少一次;被抢断的任务重发会增加 ----
|
||||||
|
if got := sp.n.Load(); got < int64(nQueued+nInterrupts) {
|
||||||
|
t.Fatalf("LLM 调用=%d,少于任务数 %d(有任务没跑到 LLM)", got, nQueued+nInterrupts)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("压力通过:%d 排队 + %d 中断(L1/L2/L3/L4 各 %d)",
|
||||||
|
nQueued, nInterrupts, nInterrupts/len(levels))
|
||||||
|
t.Logf(" Executed=%d Rejected=%d Suspended=%d Resumed=%d LLM完成=%d LLM被取消=%d",
|
||||||
|
snap.Stats.Executed, snap.Stats.Rejected, snap.Stats.Suspended, snap.Stats.Resumed,
|
||||||
|
sp.n.Load(), sp.cancelled.Load())
|
||||||
|
t.Logf(" 登记分级=%v 抢断分级=%v",
|
||||||
|
snap.Stats.InterruptsByLevel, snap.Stats.PreemptsByLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase B:把嵌套压到**结构上限**——排队(L0) ← L1 ← L2 ← L3 ← L4(运行中) = 4 帧。
|
||||||
|
//
|
||||||
|
// Phase A 的形状(100+100 混合)里,中断按严格优先级排队,同一时刻通常只有一层
|
||||||
|
// 嵌套;真正难的是「中断被中断」逐级下潜。这里按级别**逐级**注入:每一级都等到
|
||||||
|
// 上一级正在运行才注入,于是必然层层挂起,直到 L4 之上没有更高级别为止。
|
||||||
|
//
|
||||||
|
// 然后再验证恢复是**严格 LIFO**:L3 → L2 → L1 → 排队任务。
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func waitForRunning(a *Agent, pred func(*Task) bool, wait time.Duration) *Task {
|
||||||
|
deadline := time.Now().Add(wait)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if r := a.DumpScheduler().Running; r != nil && pred(r) {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStress_NestingReachesStructuralBoundThenUnwindsLIFO(t *testing.T) {
|
||||||
|
const llmDelay = 300 * time.Millisecond // 窗口要够宽,让每次 cancel 都来得及生效
|
||||||
|
|
||||||
|
plugin.RegisterFactory("stress_nest_builtin", func(string, map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
sp := &fixedDelayProvider{delay: llmDelay}
|
||||||
|
bus := newLevelRecorder()
|
||||||
|
a := New(AgentConfig{
|
||||||
|
ID: "stress-nest",
|
||||||
|
Provider: sp,
|
||||||
|
ProviderManager: agentAPI.NewProviderManager(),
|
||||||
|
IO: agentIO.NewIOManager(),
|
||||||
|
StageHost: NewStageHost(),
|
||||||
|
EventBus: bus.bus,
|
||||||
|
PluginReg: plugin.NewRegistry(),
|
||||||
|
})
|
||||||
|
a.Start()
|
||||||
|
defer a.Stop()
|
||||||
|
|
||||||
|
// 放一个排队任务(无级别)当栈底。
|
||||||
|
a.io.InjectInput("cli", "text", map[string]interface{}{"content": "nest-base"})
|
||||||
|
if r := waitForRunning(a, func(tk *Task) bool { return tk.Class == TaskQueued }, 5*time.Second); r == nil {
|
||||||
|
t.Fatal("排队任务未开始运行")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 逐级下潜:L1 → L2 → L3 → L4(L4 必须来自内核级来源)。
|
||||||
|
type step struct {
|
||||||
|
lv Level
|
||||||
|
src string
|
||||||
|
run Level // 注入前必须在运行的级别
|
||||||
|
}
|
||||||
|
steps := []step{
|
||||||
|
{LevelBackground, "cli", 0}, // 受害者=排队任务
|
||||||
|
{LevelMessage, "cli", LevelBackground}, // 受害者=L1
|
||||||
|
{LevelInteractive, "cli", LevelMessage}, // 受害者=L2
|
||||||
|
{LevelCritical, "stress_nest_builtin", LevelInteractive}, // 受害者=L3
|
||||||
|
}
|
||||||
|
for i, st := range steps {
|
||||||
|
if waitForRunning(a, func(tk *Task) bool {
|
||||||
|
if st.lv == LevelBackground {
|
||||||
|
return tk.Class == TaskQueued
|
||||||
|
}
|
||||||
|
return tk.Class == TaskInterrupt && tk.Level == st.run
|
||||||
|
}, 5*time.Second) == nil {
|
||||||
|
t.Fatalf("第 %d 级(%v)注入前未等到预期的受害者运行", i+1, st.lv)
|
||||||
|
}
|
||||||
|
a.io.InjectInterruptTextOpts(st.src, "cli", fmt.Sprintf("nest-%d", i+1),
|
||||||
|
agentIO.InjectOptions{Priority: map[Level]string{
|
||||||
|
LevelBackground: "L1", LevelMessage: "L2",
|
||||||
|
LevelInteractive: "L3", LevelCritical: "L4",
|
||||||
|
}[st.lv]})
|
||||||
|
|
||||||
|
// 等这一层真的压进栈(否则下一级的"受害者"条件会被误判)。
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for {
|
||||||
|
if len(a.DumpScheduler().SuspendStack) >= i+1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("第 %d 级注入后栈深未达 %d:%d",
|
||||||
|
i+1, i+1, len(a.DumpScheduler().SuspendStack))
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结构上限:4 帧 = 排队(L0) + L1 + L2 + L3 挂起,L4 运行中。
|
||||||
|
if n := len(a.DumpScheduler().SuspendStack); n != 4 {
|
||||||
|
t.Fatalf("嵌套峰值栈深=%d,期望 4(结构上限)", n)
|
||||||
|
}
|
||||||
|
if a.sched.canSuspend() {
|
||||||
|
t.Fatal("已到结构上限,L4 之上不该再有下潜余量")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排空:4 层必须逐层弹回,且顺序严格 LIFO。
|
||||||
|
snap := waitQuiescent(t, a, 5, 30*time.Second)
|
||||||
|
if n := len(snap.SuspendStack); n != 0 {
|
||||||
|
t.Fatalf("排空后中断栈=%d,期望 0", n)
|
||||||
|
}
|
||||||
|
if got, want := bus.resumeLevels(), []int{3, 2, 1, 0}; !equalInts(got, want) {
|
||||||
|
t.Fatalf("恢复顺序=%v,期望 LIFO %v", got, want)
|
||||||
|
}
|
||||||
|
// 峰值栈深也就是本次全部挂起帧数:4。
|
||||||
|
if snap.Stats.Suspended != 4 || snap.Stats.Resumed != 4 {
|
||||||
|
t.Fatalf("Suspended=%d Resumed=%d,期望各 4", snap.Stats.Suspended, snap.Stats.Resumed)
|
||||||
|
}
|
||||||
|
if sp.cancelled.Load() == 0 {
|
||||||
|
t.Fatal("逐级下潜必须靠取消流式段生效,却没有一次 LLM 调用被取消")
|
||||||
|
}
|
||||||
|
t.Logf("嵌套压力通过:栈深峰值=4(结构上限),恢复顺序 LIFO=%v,流式段被取消=%d 次",
|
||||||
|
bus.resumeLevels(), sp.cancelled.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// levelRecorder 记录 scheduler 事件的挂起/恢复级别。
|
||||||
|
type levelRecorder struct {
|
||||||
|
bus *events.Bus
|
||||||
|
mu sync.Mutex
|
||||||
|
resume []int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLevelRecorder() *levelRecorder {
|
||||||
|
rec := &levelRecorder{bus: events.NewBus()}
|
||||||
|
rec.bus.Subscribe(events.EventScheduler, func(e *events.Event) {
|
||||||
|
if fmt.Sprint(e.Payload["action"]) != "resume" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lv, _ := e.Payload["level"].(int)
|
||||||
|
rec.mu.Lock()
|
||||||
|
rec.resume = append(rec.resume, lv)
|
||||||
|
rec.mu.Unlock()
|
||||||
|
})
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *levelRecorder) resumeLevels() []int {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return append([]int(nil), r.resume...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalInts(a, b []int) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
253
internal/agent/core/scheduler_test.go
Normal file
253
internal/agent/core/scheduler_test.go
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// M2 验收测试:调度器骨架(就绪队列、选择函数、快照、panic 隔离)。
|
||||||
|
//
|
||||||
|
// 设计依据 docs/zh/input-scheduler-design.md §11.4(Q1/Q4)与 §11.5(O1/K1)。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 新模型的选择顺序:immediate → 中断队列 L4→L1 → 栈顶(与队头比级别) → 排队 FIFO。
|
||||||
|
func TestScheduler_SelectionOrder(t *testing.T) {
|
||||||
|
s := newScheduler(16)
|
||||||
|
|
||||||
|
// 四条中断队列各放一个,入队顺序与级别相反 —— 验证“按级别扫”而非 FIFO。
|
||||||
|
for _, lv := range []Level{LevelBackground, LevelMessage, LevelInteractive, LevelCritical} {
|
||||||
|
evt, _ := textEvent("qq", "中断")
|
||||||
|
s.registerInterrupt(newInterruptTask(evt, lv))
|
||||||
|
}
|
||||||
|
// 排队任务两条(无级别,FIFO)。
|
||||||
|
s.enqueue(newSelfTask(selfInputMsg{text: "q1"}))
|
||||||
|
s.enqueue(newSelfTask(selfInputMsg{text: "q2"}))
|
||||||
|
|
||||||
|
var order []Level
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
task, _, kind := s.nextRef()
|
||||||
|
if kind != nextInterrupt {
|
||||||
|
t.Fatalf("第 %d 个应来自中断队列,kind=%v", i+1, kind)
|
||||||
|
}
|
||||||
|
order = append(order, task.Level)
|
||||||
|
s.done(task)
|
||||||
|
}
|
||||||
|
want := []Level{LevelCritical, LevelInteractive, LevelMessage, LevelBackground}
|
||||||
|
for i := range want {
|
||||||
|
if order[i] != want[i] {
|
||||||
|
t.Fatalf("中断执行顺序=%v,期望 %v", order, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中断耗尽后才是排队任务,且保持 FIFO。
|
||||||
|
for i := 1; i <= 2; i++ {
|
||||||
|
task, _, kind := s.nextRef()
|
||||||
|
if kind != nextReady {
|
||||||
|
t.Fatalf("中断耗尽后应取排队任务,kind=%v", kind)
|
||||||
|
}
|
||||||
|
if task.Self.text != fmt.Sprintf("q%d", i) {
|
||||||
|
t.Fatalf("排队任务应 FIFO,第 %d 个=%q", i, task.Self.text)
|
||||||
|
}
|
||||||
|
s.done(task)
|
||||||
|
}
|
||||||
|
if _, _, kind := s.nextRef(); kind != nextNone {
|
||||||
|
t.Fatal("全空后应返回 nextNone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// immediate(刚抢占成功的中断)必须最先运行——哪怕队列里有更高级别的待处理中断。
|
||||||
|
// 这是“抢占立即生效”的实现方式,也是它不需要和栈顶比级别的原因。
|
||||||
|
func TestScheduler_ImmediateWins(t *testing.T) {
|
||||||
|
s := newScheduler(16)
|
||||||
|
evt1, _ := textEvent("cli", "L4 待处理")
|
||||||
|
s.registerInterrupt(newKernelInterruptTask(evt1))
|
||||||
|
|
||||||
|
evt2, _ := textEvent("qq", "抢占者")
|
||||||
|
preemptor := newInterruptTask(evt2, LevelBackground)
|
||||||
|
s.mu.Lock()
|
||||||
|
s.setImmediateLocked(preemptor)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
task, _, kind := s.nextRef()
|
||||||
|
if kind != nextImmediate || task != preemptor {
|
||||||
|
t.Fatalf("immediate 必须先运行,kind=%v", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中断队列头与中断栈顶比级别,取高者;栈顶是排队任务(无级别)时任何中断都赢。
|
||||||
|
func TestScheduler_StackTopVsInterruptQueue(t *testing.T) {
|
||||||
|
s := newScheduler(16)
|
||||||
|
// 直接构造挂起现场:不走 suspend(),避免 PreemptCount/冷却干扰本用例
|
||||||
|
// (本用例只测“选择顺序”这一件事)。
|
||||||
|
pushSuspended := func(id uint64, class TaskClass, lv Level) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.suspendStack = append(s.suspendStack, &suspendedTask{
|
||||||
|
Task: &Task{ID: id, Class: class, Level: lv}, Frame: &TaskFrame{},
|
||||||
|
})
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
// 每次选取后清掉 running,让下一次 registerInterrupt 不把它当成运行任务。
|
||||||
|
clearRunning := func() {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.running = nil
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 栈顶 L3,队列只有 L2 → 恢复栈顶。
|
||||||
|
pushSuspended(1, TaskInterrupt, LevelInteractive)
|
||||||
|
evt, _ := textEvent("qq", "L2 待处理")
|
||||||
|
s.registerInterrupt(newInterruptTask(evt, LevelMessage))
|
||||||
|
if _, _, kind := s.nextRef(); kind != nextSuspended {
|
||||||
|
t.Fatalf("栈顶 L3 > 队头 L2 → 应恢复栈顶,kind=%v", kind)
|
||||||
|
}
|
||||||
|
clearRunning()
|
||||||
|
|
||||||
|
// 栈顶 L3,队列来了 L4 → 队头优先。
|
||||||
|
pushSuspended(2, TaskInterrupt, LevelInteractive)
|
||||||
|
evt2, _ := textEvent("cli", "L4 待处理")
|
||||||
|
s.registerInterrupt(newKernelInterruptTask(evt2))
|
||||||
|
if _, _, kind := s.nextRef(); kind != nextInterrupt {
|
||||||
|
t.Fatalf("队头 L4 > 栈顶 L3 → 应先取中断,kind=%v", kind)
|
||||||
|
}
|
||||||
|
clearRunning()
|
||||||
|
|
||||||
|
// 栈顶是排队任务(无级别)→ 任何中断都赢。
|
||||||
|
pushSuspended(3, TaskQueued, 0)
|
||||||
|
evt3, _ := textEvent("qq", "L1 待处理")
|
||||||
|
s.registerInterrupt(newInterruptTask(evt3, LevelBackground))
|
||||||
|
if _, _, kind := s.nextRef(); kind != nextInterrupt {
|
||||||
|
t.Fatalf("排队栈顶可被任何中断打断,kind=%v", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Q4:队列有界;满了必须拒绝并计数,而不是静默丢弃或无界增长。
|
||||||
|
func TestScheduler_EnqueueBackpressure(t *testing.T) {
|
||||||
|
s := newScheduler(2)
|
||||||
|
if !s.enqueue(newSelfTask(selfInputMsg{text: "a"})) {
|
||||||
|
t.Fatal("第 1 个任务应入队成功")
|
||||||
|
}
|
||||||
|
if !s.enqueue(newSelfTask(selfInputMsg{text: "b"})) {
|
||||||
|
t.Fatal("第 2 个任务应入队成功")
|
||||||
|
}
|
||||||
|
if s.hasRoom() {
|
||||||
|
t.Fatal("队列已满,hasRoom 应为 false")
|
||||||
|
}
|
||||||
|
if s.enqueue(newSelfTask(selfInputMsg{text: "c"})) {
|
||||||
|
t.Fatal("队列满时第 3 个任务必须被拒绝")
|
||||||
|
}
|
||||||
|
if s.stats.Rejected != 1 {
|
||||||
|
t.Fatalf("Rejected=%d,期望 1", s.stats.Rejected)
|
||||||
|
}
|
||||||
|
if s.stats.Enqueued != 2 {
|
||||||
|
t.Fatalf("Enqueued=%d,期望 2", s.stats.Enqueued)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生命周期:next 置 running 并移出队列;done 清 running 并累加计数。
|
||||||
|
func TestScheduler_Lifecycle(t *testing.T) {
|
||||||
|
s := newScheduler(4)
|
||||||
|
s.enqueue(newSelfTask(selfInputMsg{text: "a"}))
|
||||||
|
s.enqueue(newSelfTask(selfInputMsg{text: "b"}))
|
||||||
|
|
||||||
|
t1 := s.next()
|
||||||
|
if t1 == nil || s.running != t1 {
|
||||||
|
t.Fatal("next 应取出任务并置为 running")
|
||||||
|
}
|
||||||
|
if len(s.queue) != 1 {
|
||||||
|
t.Fatalf("取出后队列长度=%d,期望 1", len(s.queue))
|
||||||
|
}
|
||||||
|
// 队列内不得同时出现 running(O1:三集合互不重叠)。
|
||||||
|
for _, q := range s.queue {
|
||||||
|
if q == t1 {
|
||||||
|
t.Fatal("running 任务不得同时留在就绪队列")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.done(t1)
|
||||||
|
if s.running != nil {
|
||||||
|
t.Fatal("done 后 running 应为 nil")
|
||||||
|
}
|
||||||
|
if s.stats.Executed != 1 {
|
||||||
|
t.Fatalf("Executed=%d,期望 1", s.stats.Executed)
|
||||||
|
}
|
||||||
|
if s.next() == nil {
|
||||||
|
t.Fatal("队列里还有 b,next 不应为 nil")
|
||||||
|
}
|
||||||
|
if s.next() != nil {
|
||||||
|
t.Fatal("队列已空,next 应返回 nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// K1:任务 panic 必须被隔离——调度器统计仍然推进,且不向外抛出。
|
||||||
|
func TestScheduler_PanicIsolationOnExecuteTask(t *testing.T) {
|
||||||
|
sp := &scriptProvider{}
|
||||||
|
a := New(AgentConfig{
|
||||||
|
ID: "sched-panic",
|
||||||
|
Provider: sp,
|
||||||
|
ProviderManager: agentAPI.NewProviderManager(),
|
||||||
|
IO: agentIO.NewIOManager(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Event 为 nil:handleInput 解引用即 panic,用来验证 recover 生效。
|
||||||
|
task := &Task{Kind: TaskKindInput, Level: DefaultLevel, Event: nil}
|
||||||
|
a.executeTask(task) // 若未隔离,这里会 panic 冒泡使测试失败
|
||||||
|
|
||||||
|
if a.sched.stats.Executed != 1 {
|
||||||
|
t.Fatalf("panic 后 Executed=%d,期望 1(任务失败但调度器存活)", a.sched.stats.Executed)
|
||||||
|
}
|
||||||
|
if a.sched.running != nil {
|
||||||
|
t.Fatal("panic 后 running 必须被清空")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// O1 轻量版:快照与内部状态一致,且 running 不出现在 queue 里。
|
||||||
|
func TestScheduler_SnapshotConsistency(t *testing.T) {
|
||||||
|
sp := &scriptProvider{}
|
||||||
|
a := New(AgentConfig{
|
||||||
|
ID: "sched-snap",
|
||||||
|
Provider: sp,
|
||||||
|
ProviderManager: agentAPI.NewProviderManager(),
|
||||||
|
IO: agentIO.NewIOManager(),
|
||||||
|
})
|
||||||
|
|
||||||
|
a.sched.enqueue(newSelfTask(selfInputMsg{text: "a"}))
|
||||||
|
a.sched.enqueue(newSelfTask(selfInputMsg{text: "b"}))
|
||||||
|
snap := a.DumpScheduler()
|
||||||
|
if snap.Running != nil {
|
||||||
|
t.Fatal("尚未 next,快照的 running 应为 nil")
|
||||||
|
}
|
||||||
|
if len(snap.Queue) != 2 || snap.Stats.Enqueued != 2 {
|
||||||
|
t.Fatalf("快照不一致:queue=%d enqueued=%d", len(snap.Queue), snap.Stats.Enqueued)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := a.sched.next()
|
||||||
|
a.executeTask(&Task{Kind: TaskKindSelf, Level: DefaultLevel, Self: selfInputMsg{text: "a"}})
|
||||||
|
snap = a.DumpScheduler()
|
||||||
|
if snap.Running != r {
|
||||||
|
t.Fatal("执行完成后 running 应仍指向未 done 的任务")
|
||||||
|
}
|
||||||
|
for _, q := range snap.Queue {
|
||||||
|
if q == r {
|
||||||
|
t.Fatal("快照中 running 与 queue 不得重叠")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 队列快照必须是副本:改快照不得影响调度器。
|
||||||
|
snap.Queue = append(snap.Queue, &Task{})
|
||||||
|
if len(a.DumpScheduler().Queue) != 1 {
|
||||||
|
t.Fatal("DumpScheduler 必须返回队列副本")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level 的字面量是持久化/日志契约,改值必须是有意的。
|
||||||
|
func TestLevelContract(t *testing.T) {
|
||||||
|
if LevelBackground != 1 || LevelMessage != 2 || LevelInteractive != 3 || LevelCritical != 4 {
|
||||||
|
t.Fatalf("四级取值被改动:%d/%d/%d/%d",
|
||||||
|
LevelBackground, LevelMessage, LevelInteractive, LevelCritical)
|
||||||
|
}
|
||||||
|
if DefaultLevel != LevelBackground {
|
||||||
|
t.Fatalf("默认级必须是 L1(显式才是特权),实际 %v", DefaultLevel)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -45,7 +45,7 @@ func (a *Agent) evictChildTasksLocked() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
|
func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall, parentChannel string) string {
|
||||||
task, _ := tc.Arguments["task"].(string)
|
task, _ := tc.Arguments["task"].(string)
|
||||||
if task == "" {
|
if task == "" {
|
||||||
if b, _ := json.Marshal(tc.Arguments); len(b) > 2 {
|
if b, _ := json.Marshal(tc.Arguments); len(b) > 2 {
|
||||||
@ -69,9 +69,8 @@ func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
|
|||||||
taskID := fmt.Sprintf("child_%d", a.childNextID)
|
taskID := fmt.Sprintf("child_%d", a.childNextID)
|
||||||
a.childMu.Unlock()
|
a.childMu.Unlock()
|
||||||
|
|
||||||
// 捕获父 Agent 当前输出通道:子任务完成通知需回到发起对话的通道,
|
// parentChannel 由调用方(任务帧)传入:子任务完成通知要回到**发起这次
|
||||||
// 让父 Agent 正常感知并可回复用户(而非走无记忆整理路径丢失通知)。
|
// spawn 的那个任务**的通道,而不是"内核当前通道"(那个概念已删除)。
|
||||||
parentChannel := a.currentOutputChannel
|
|
||||||
if parentChannel == "" || parentChannel == channelConsolidation {
|
if parentChannel == "" || parentChannel == channelConsolidation {
|
||||||
parentChannel = "cli"
|
parentChannel = "cli"
|
||||||
}
|
}
|
||||||
@ -150,7 +149,7 @@ func (a *Agent) runChildTask(taskID, task string, parentChannel string, maxTurns
|
|||||||
case ct.Name == "spawn_child" || ct.Name == "plgreload":
|
case ct.Name == "spawn_child" || ct.Name == "plgreload":
|
||||||
result = fmt.Sprintf("子 Agent 不允许调用系统工具: %s", ct.Name)
|
result = fmt.Sprintf("子 Agent 不允许调用系统工具: %s", ct.Name)
|
||||||
default:
|
default:
|
||||||
result = a.executeToolCall(ct)
|
result = a.executeToolCall(ct, parentChannel)
|
||||||
}
|
}
|
||||||
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{ct}})
|
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{ct}})
|
||||||
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: ct.ID, Content: result})
|
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: ct.ID, Content: result})
|
||||||
@ -249,5 +248,3 @@ func (a *Agent) executeLLMTool(tc agentAPI.ToolCall) string {
|
|||||||
return fmt.Sprintf("未知的 LLM 工具: %s", tc.Name)
|
return fmt.Sprintf("未知的 LLM 工具: %s", tc.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -12,9 +12,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (a *Agent) runStage(stage sdk.Stage, ctx *sdk.StageContext) bool {
|
func (a *Agent) runStage(stage sdk.Stage, ctx *sdk.StageContext) bool {
|
||||||
|
// 通道从 stage ctx 上取(由发起方写入)——内核不持有"当前通道"。
|
||||||
|
ch := ""
|
||||||
|
if ctx != nil && ctx.Extra != nil {
|
||||||
|
ch, _ = ctx.Extra["output_channel"].(string)
|
||||||
|
}
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"phase": string(stage),
|
"phase": string(stage),
|
||||||
"channel": a.currentOutputChannel,
|
"channel": ch,
|
||||||
}
|
}
|
||||||
if ctx != nil && len(ctx.ToolCalls) > 0 {
|
if ctx != nil && len(ctx.ToolCalls) > 0 {
|
||||||
payload["tool"] = ctx.ToolCalls[0].Name
|
payload["tool"] = ctx.ToolCalls[0].Name
|
||||||
|
|||||||
@ -156,7 +156,6 @@ func collectKernelStatus(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Tracker
|
// Tracker
|
||||||
if trk != nil {
|
if trk != nil {
|
||||||
status.Tracker.Available = true
|
status.Tracker.Available = true
|
||||||
@ -192,7 +191,6 @@ func (a *Agent) GetKernelStatus() *KernelStatus {
|
|||||||
socialStore = a.social
|
socialStore = a.social
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
var trk *tracker.Tracker
|
var trk *tracker.Tracker
|
||||||
if a.tracker != nil {
|
if a.tracker != nil {
|
||||||
trk = a.tracker
|
trk = a.tracker
|
||||||
@ -222,6 +220,7 @@ func (a *Agent) GetKernelStatus() *KernelStatus {
|
|||||||
trk,
|
trk,
|
||||||
)
|
)
|
||||||
ks.ONNX = a.onnxStatus()
|
ks.ONNX = a.onnxStatus()
|
||||||
|
ks.Scheduler = a.schedulerStatus()
|
||||||
|
|
||||||
return ks
|
return ks
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +26,7 @@ func TestAccumulateStreamToolCalls(t *testing.T) {
|
|||||||
close(ch)
|
close(ch)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
resp, err := accumulateStream(context.Background(), ch, nil)
|
resp, err := accumulateStream(context.Background(), ch, nil, "cli")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("accumulateStream: %v", err)
|
t.Fatalf("accumulateStream: %v", err)
|
||||||
}
|
}
|
||||||
@ -56,7 +56,7 @@ func TestAccumulateStreamContent(t *testing.T) {
|
|||||||
ch <- agentAPI.StreamChunk{Done: true, FinishReason: "stop"}
|
ch <- agentAPI.StreamChunk{Done: true, FinishReason: "stop"}
|
||||||
close(ch)
|
close(ch)
|
||||||
}()
|
}()
|
||||||
resp, err := accumulateStream(context.Background(), ch, nil)
|
resp, err := accumulateStream(context.Background(), ch, nil, "cli")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("accumulateStream: %v", err)
|
t.Fatalf("accumulateStream: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,7 +41,7 @@ func TestAccumulateStreamParallelToolCallsByIndex(t *testing.T) {
|
|||||||
}
|
}
|
||||||
close(ch)
|
close(ch)
|
||||||
|
|
||||||
resp, err := accumulateStream(ctx, ch, nil)
|
resp, err := accumulateStream(ctx, ch, nil, "cli")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("accumulateStream: %v", err)
|
t.Fatalf("accumulateStream: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
934
internal/agent/core/task.go
Normal file
934
internal/agent/core/task.go
Normal file
@ -0,0 +1,934 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// 任务状态机(M1:行为等价的纯重构)。
|
||||||
|
//
|
||||||
|
// 背景与设计见 docs/zh/input-scheduler-design.md。
|
||||||
|
//
|
||||||
|
// M1 只做一件事:把原先「一个 425 行的 process() 大函数」拆成
|
||||||
|
// **显式 step 游标 + TaskFrame**。目的不是加能力,而是让「现场」变成数据——
|
||||||
|
// 之后 M3 才能把帧存进 suspendStack 并在安全点恢复。
|
||||||
|
//
|
||||||
|
// 行为等价的判据:既有全部 agent 测试通过,且 R3/X3(见设计文档 §11)通过。
|
||||||
|
//
|
||||||
|
// 本文件**不引入**优先级、抢占、队列与并发;那些在 M2 起逐层加上。
|
||||||
|
//
|
||||||
|
// Step 与安全点(设计文档 §4.2):
|
||||||
|
// step 与 step 之间是安全点;StepToolExec(工具执行)与 StepPrepare 中的
|
||||||
|
// ONNX/落盘片段是**临界区**,执行中不可抢占。
|
||||||
|
//
|
||||||
|
// 与设计文档的差异:文档里的 StepBeforeOutput / StepAfterOutput / StepCommit /
|
||||||
|
// StepFinish 属于 emitResponse 与 processInput 层,M1 不动它们(M6 再迁入帧)。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||||
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
|
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Step 是任务状态机的游标。
|
||||||
|
type Step int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// StepPrepare 构建消息与工具表、应用中断标记、跑 pre_action 阶段。
|
||||||
|
StepPrepare Step = iota
|
||||||
|
// StepLLM 轮次顶部(中断/占位)+ LLM 调用(含 provider 回退与重试)+ post_action。
|
||||||
|
StepLLM
|
||||||
|
// StepToolBegin 取本批下一个工具,跑 before_toolcall;被拒/插件不健康则跳过。
|
||||||
|
StepToolBegin
|
||||||
|
// StepToolExec 执行工具。**临界区**:副作用不可回滚,执行中不是安全点。
|
||||||
|
StepToolExec
|
||||||
|
// StepToolAfter after_toolcall 阶段、上下文裁剪、消息与事件组装、批后中断检查。
|
||||||
|
StepToolAfter
|
||||||
|
// StepTurnEnd 收尾本批并进入下一轮。
|
||||||
|
StepTurnEnd
|
||||||
|
)
|
||||||
|
|
||||||
|
// stepOutcome 是一次 step 执行的结果。
|
||||||
|
type stepOutcome int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// outcomeContinue 继续执行下一个 step(游标可能停在原地以表达"重跑本 step")。
|
||||||
|
outcomeContinue stepOutcome = iota
|
||||||
|
// outcomeDone 任务成功结束,响应在 frame.Response。
|
||||||
|
outcomeDone
|
||||||
|
// outcomeFailed 任务失败结束,错误在 frame.Err。
|
||||||
|
outcomeFailed
|
||||||
|
// outcomeSuspended 任务在安全点被抢占挂起,帧已保存(M3b 起使用)。
|
||||||
|
outcomeSuspended
|
||||||
|
)
|
||||||
|
|
||||||
|
// taskTerminal 是任务的终态种类(设计文档 §7:每个任务恰有一个终态)。
|
||||||
|
type taskTerminal int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// terminalNone 任务尚未结束,需进入 run 段。
|
||||||
|
terminalNone taskTerminal = iota
|
||||||
|
// terminalOK 正常完成(已提交上下文并回执)。
|
||||||
|
terminalOK
|
||||||
|
// terminalError 执行出错(已提交错误响应)。
|
||||||
|
terminalError
|
||||||
|
// terminalStageShortCircuit 被 on_input 阶段短路(响应已发出)。
|
||||||
|
terminalStageShortCircuit
|
||||||
|
// terminalSkipped 未进入执行:解析失败或被去重。
|
||||||
|
terminalSkipped
|
||||||
|
// terminalConsolidation 走记忆整理专用路径,已处理完毕。
|
||||||
|
terminalConsolidation
|
||||||
|
// terminalSuspended 被抢占挂起,等待恢复(M3b 起使用)。
|
||||||
|
terminalSuspended
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskFrame 承载一个任务在安全点之间必须存活的所有状态。
|
||||||
|
//
|
||||||
|
// 不变量(设计文档 §8.1 I3):帧是**纯数据**;不得持有任何锁或资源跨越安全点。
|
||||||
|
type TaskFrame struct {
|
||||||
|
Input string
|
||||||
|
StageCtx *sdk.StageContext
|
||||||
|
|
||||||
|
// 跨轮次状态
|
||||||
|
Msgs []agentAPI.Message
|
||||||
|
Tools []interface{}
|
||||||
|
ToolsUsed []string
|
||||||
|
ToolResults []ToolResultItem
|
||||||
|
Turn int
|
||||||
|
LastBatchReplyOnly bool
|
||||||
|
|
||||||
|
// 当前工具批
|
||||||
|
PendingTools []agentAPI.ToolCall
|
||||||
|
ToolIdx int
|
||||||
|
ReplyOnly bool
|
||||||
|
ContentOnce bool
|
||||||
|
CurTool agentAPI.ToolCall
|
||||||
|
CurToolPlugin string
|
||||||
|
CurResult string
|
||||||
|
Resp *agentAPI.CompletionResponse
|
||||||
|
|
||||||
|
// 游标与终态
|
||||||
|
Step Step
|
||||||
|
Response string
|
||||||
|
Err error
|
||||||
|
|
||||||
|
// ---- 任务层现场(原 processInput 的局部变量)----
|
||||||
|
//
|
||||||
|
// 这些字段让帧覆盖 prepare → step… → finish 全生命周期:挂起发生在 run 段的
|
||||||
|
// 安全点,恢复后由 finish 段统一提交(context.Append + emitResponse +
|
||||||
|
// emitMemoryCandidate),因此挂起不会重复提交。
|
||||||
|
Evt *agentIO.InputEvent
|
||||||
|
CleanInput string
|
||||||
|
IsInterrupt bool
|
||||||
|
StartedAt time.Time
|
||||||
|
Terminal taskTerminal
|
||||||
|
// OutputChannel 是本任务的输出通道(来源通道的稳定副本)。
|
||||||
|
//
|
||||||
|
// 这是本任务通道的**唯一**来源:内核不持有"当前通道"可变状态(N0 已删除
|
||||||
|
// Agent.currentOutputChannel)。那类字段会被后来的任务覆盖,而被打断任务
|
||||||
|
// 恢复时不重新 prepare(resumeTask 只 rebase 前缀),于是两任务串台——
|
||||||
|
// 被打断任务的回复发到中断任务的通道上(见
|
||||||
|
// TestPreempt_ResumeKeepsOwnOutputChannel)。
|
||||||
|
OutputChannel string
|
||||||
|
|
||||||
|
// PrefixLen 是 stepPrepare 构建的**基础前缀**长度(system + timeline + 用户输入)。
|
||||||
|
// 恢复时用它把「本任务自己的现场」接回重建后的前缀之上(见 rebaseFramePrefix)。
|
||||||
|
PrefixLen int
|
||||||
|
// InputBlocks 是本轮输入携带的多模态块;重建前缀时要重新挂回。
|
||||||
|
InputBlocks []agentAPI.ContentBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
// outputChannelOf 从**输入事件**推导本次输出应走的通道。
|
||||||
|
//
|
||||||
|
// 内核不持有"当前通道"可变状态:那类字段会被后来的任务(中断任务)覆盖,
|
||||||
|
// 使被打断任务恢复后的提示词/事件标签串台。通道只跟着事件与帧走。
|
||||||
|
func outputChannelOf(evt *agentIO.InputEvent) string {
|
||||||
|
if evt == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if evt.OutputChannel != "" {
|
||||||
|
return evt.OutputChannel
|
||||||
|
}
|
||||||
|
return evt.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
// isCriticalChannel 报告某个通道是否是**整任务不可抢占**的临界区。
|
||||||
|
//
|
||||||
|
// 目前只有 `_consolidation_`(记忆整理直接改图库)。工具执行/ONNX/CAS 属于
|
||||||
|
// **单步**临界区,由"只在 step 之间检查让位"天然保护,不在这里列。
|
||||||
|
func isCriticalChannel(channel string) bool {
|
||||||
|
return channel == channelConsolidation
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) newTaskFrame(input string, stageCtx *sdk.StageContext) *TaskFrame {
|
||||||
|
return &TaskFrame{Input: input, StageCtx: stageCtx, Step: StepPrepare}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runTaskSteps 驱动状态机直到任务结束或被抢占挂起。
|
||||||
|
//
|
||||||
|
// 这是 M1 的驱动循环,M3a 从 process() 抽出来,使调用方可以拿到
|
||||||
|
// outcomeSuspended 并把帧留给调度器保存。
|
||||||
|
func (a *Agent) runTaskSteps(f *TaskFrame) stepOutcome {
|
||||||
|
// 步数上限只是防"转移缺失导致死循环"的护栏;正常任务远达不到。
|
||||||
|
const maxSteps = 1 << 20
|
||||||
|
for i := 0; i < maxSteps; i++ {
|
||||||
|
// 安全点:只在 step 之间检查让位。临界区(StepToolExec)不在此列,
|
||||||
|
// 因为让位信号由 interruptLoop 置位、而本循环是唯一读帧者。
|
||||||
|
if !isCriticalChannel(f.OutputChannel) && a.sched.preemptGrantedFor() && a.sched.canSuspend() {
|
||||||
|
return outcomeSuspended
|
||||||
|
}
|
||||||
|
// 工具轮次硬上限(设计文档 D6):在发起下一轮 LLM 前收尾。
|
||||||
|
// f.Turn 只在 stepTurnEnd 递增,所以它等于「已完成的工具批数」;
|
||||||
|
// 因此这里允许 maxToolTurns 批,而不会多跑第 maxToolTurns+1 轮。
|
||||||
|
if f.Step == StepLLM && a.maxToolTurns > 0 && f.Turn >= a.maxToolTurns {
|
||||||
|
log.Printf("[agent] 已达最大工具轮次 %d(turn=%d),强制收尾", a.maxToolTurns, f.Turn)
|
||||||
|
if f.Resp != nil && strings.TrimSpace(f.Resp.Content) != "" {
|
||||||
|
f.Response = f.Resp.Content
|
||||||
|
} else {
|
||||||
|
f.Response = fmt.Sprintf("[系统] 已达到最大工具轮次 %d,任务中止。", a.maxToolTurns)
|
||||||
|
}
|
||||||
|
return outcomeDone
|
||||||
|
}
|
||||||
|
switch a.step(f) {
|
||||||
|
case outcomeDone:
|
||||||
|
return outcomeDone
|
||||||
|
case outcomeFailed:
|
||||||
|
return outcomeFailed
|
||||||
|
case outcomeSuspended:
|
||||||
|
return outcomeSuspended
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.Err = fmt.Errorf("agent: task step budget exhausted(状态机未收敛,疑似转移缺失)")
|
||||||
|
return outcomeFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
// process 是保留给 processConsolidation 与测试的薄壳,返回与原实现相同的四元组。
|
||||||
|
//
|
||||||
|
// 注意:M3a 起**不再持 a.mu**——调度器是唯一执行者,而挂起不能持锁。
|
||||||
|
func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response string, toolsUsed []string, toolResults []ToolResultItem, err error) {
|
||||||
|
if a.provider == nil {
|
||||||
|
return "", nil, nil, fmt.Errorf("agent: no LLM provider configured")
|
||||||
|
}
|
||||||
|
f := a.newTaskFrame(input, stageCtx)
|
||||||
|
switch a.runTaskSteps(f) {
|
||||||
|
case outcomeDone:
|
||||||
|
return f.Response, f.ToolsUsed, f.ToolResults, nil
|
||||||
|
case outcomeFailed:
|
||||||
|
return "", f.ToolsUsed, f.ToolResults, f.Err
|
||||||
|
default:
|
||||||
|
// 不该发生:process() 不参与挂起(只有 runInputTask 会)。
|
||||||
|
return "", f.ToolsUsed, f.ToolResults,
|
||||||
|
fmt.Errorf("agent: task suspended outside scheduler")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runInputTask 是一个输入任务的完整生命周期:prepare → run → finish。
|
||||||
|
//
|
||||||
|
// 它是原 processInput 的全部职责,被拆成三段而不是一个大函数,目的只有一个:
|
||||||
|
// 让帧可以跨安全点被挂起——挂起后由调度器保存,恢复时接着 run 段继续,
|
||||||
|
// 而 finish 段(上下文提交与回执)只在任务真正结束时执行一次。
|
||||||
|
//
|
||||||
|
// M3a 还没有抢占,因此 outcomeSuspended 只会由 M3b 的抢占检查产生。
|
||||||
|
func (a *Agent) runInputTask(evt *agentIO.InputEvent) (*TaskFrame, stepOutcome) {
|
||||||
|
// 临界区标记由调度器 goroutine 维护,任务结束(含挂起)即清。
|
||||||
|
// interceptLoop 读它来决定“能不能取消”,因此必须是原子的。
|
||||||
|
defer a.sched.setCritical(false)
|
||||||
|
|
||||||
|
f, term := a.prepareInputTask(evt)
|
||||||
|
switch term {
|
||||||
|
case terminalSkipped, terminalStageShortCircuit, terminalConsolidation:
|
||||||
|
return nil, outcomeDone
|
||||||
|
}
|
||||||
|
|
||||||
|
out := a.runTaskSteps(f)
|
||||||
|
if out == outcomeSuspended {
|
||||||
|
f.Terminal = terminalSuspended
|
||||||
|
return f, outcomeSuspended
|
||||||
|
}
|
||||||
|
a.finishInputTask(f, out)
|
||||||
|
return f, out
|
||||||
|
}
|
||||||
|
|
||||||
|
// rebaseFramePrefix 把被挂起任务的上下文现场「加载回中断任务之上」。
|
||||||
|
//
|
||||||
|
// 语义(用户明确):
|
||||||
|
// - 中断打断时,被挂起任务自到达以来累积的全部现场(含 toolcall)被保护;
|
||||||
|
// - 中断在上一个任务之前的**完整状态**上开始运行(所以中断看不到本任务的部分进展);
|
||||||
|
// - 中断结束后,把被挂起任务与其现场加载回中断任务**之上**再继续——
|
||||||
|
// 即中断已提交的那段上下文留在下面(前缀),本任务自己的现场落回其上。
|
||||||
|
//
|
||||||
|
// 实现:重建基础前缀(system + timeline + 用户输入);由于中断结束时已把它的
|
||||||
|
// 输入/输出提交进 a.context,重建出的 timeline 已含中断的效果;再把本任务
|
||||||
|
// 自己的尾部(Stage 上下文 + 工具轮产物 + 占位)原样接回。
|
||||||
|
func (a *Agent) rebaseFramePrefix(f *TaskFrame) {
|
||||||
|
if f == nil || f.PrefixLen <= 0 || f.PrefixLen > len(f.Msgs) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tail := append([]agentAPI.Message(nil), f.Msgs[f.PrefixLen:]...)
|
||||||
|
|
||||||
|
budget := ComputeTokenBudget(a.provider, a.systemPrompt)
|
||||||
|
memContext := a.buildMemoryContext(f.Input, budget.MemoryTokens)
|
||||||
|
sysPrompt := a.buildSystemPrompt(memContext, f.Input)
|
||||||
|
prefix := a.buildMessages(sysPrompt, f.Input, a.contextTokenBudget(budget))
|
||||||
|
|
||||||
|
// 重建会丢掉 prepare 段对尾部消息的两处改写,这里等价地补回。
|
||||||
|
if f.IsInterrupt && len(prefix) > 0 {
|
||||||
|
last := prefix[len(prefix)-1]
|
||||||
|
last.Role = "system"
|
||||||
|
last.Content = "[中断消息] " + last.Content
|
||||||
|
prefix[len(prefix)-1] = last
|
||||||
|
}
|
||||||
|
if len(f.InputBlocks) > 0 && len(prefix) > 0 {
|
||||||
|
prefix[len(prefix)-1].Blocks = f.InputBlocks
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Msgs = append(prefix, tail...)
|
||||||
|
f.PrefixLen = len(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareInputTask 执行 processInput 的前半段(去重、通道解析、阶段、裁剪、
|
||||||
|
// 输入事件落上下文)。返回终态不为 terminalNone 时调用方不得进入 run 段。
|
||||||
|
func (a *Agent) prepareInputTask(evt *agentIO.InputEvent) (*TaskFrame, taskTerminal) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
in, ok := a.resolveInput(evt)
|
||||||
|
if !ok {
|
||||||
|
// 空输入(文本与媒体都空):没有可处理内容,但同步调用方仍在等回执。
|
||||||
|
a.emitSkippedReply(evt, "empty_input")
|
||||||
|
return nil, terminalSkipped
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去重按文本做:webui/GUI 断线重连会重放未确认消息。
|
||||||
|
// 带媒体时跳过——媒体输入的 alt 文案("[从 qq 收到了 image]")对不同图片
|
||||||
|
// 是同一句,拿它去重会把连发的两张图误判成重复。
|
||||||
|
if len(in.blocks) == 0 && a.isDuplicateInput(evt.Source, in.text) {
|
||||||
|
log.Printf("[agent] dropped duplicate input from %s: %s", evt.Source, truncateStr(in.text, 60))
|
||||||
|
// 去重是「不处理」而不是「不回」,否则同步调用方(cli/clawhub 无超时)
|
||||||
|
// 会永久挂起(设计文档 §7 不变量 I5、§11.3 X2/X4)。
|
||||||
|
a.emitSkippedReply(evt, "duplicate")
|
||||||
|
return nil, terminalSkipped
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通道只从**输入事件**推导,内核不持有"当前通道"可变状态
|
||||||
|
// (见 outputChannelOf;这消除了中断任务覆盖它导致被打断任务串台的整类问题)。
|
||||||
|
// 进入本任务的临界区属性(记忆整理整任务不可抢占)。
|
||||||
|
// 必须在 processConsolidation 之前设置——它就在下面同步执行。
|
||||||
|
a.sched.setCritical(isCriticalChannel(outputChannelOf(evt)))
|
||||||
|
|
||||||
|
if evt.OutputChannel == channelConsolidation {
|
||||||
|
a.processConsolidation(evt, in.text)
|
||||||
|
return nil, terminalConsolidation
|
||||||
|
}
|
||||||
|
|
||||||
|
// pendingMedia 让 describe_image / transcribe_audio / ocr_image 拿到本轮媒体的
|
||||||
|
// 原始 data/url,也是这三个工具是否出现在工具表里的开关。仅对用户直接上传成立
|
||||||
|
//(payload 里才有 data/url);插件注入的是成品 block,取不到原始数据。
|
||||||
|
if evt.Type == "image" || evt.Type == "audio" {
|
||||||
|
a.pendingMedia = evt.Payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// 媒体先落进 CAS。不存的后果是 ContextEvent.Input 只剩一句 alt 文本,
|
||||||
|
// base64 随 message 数组发给模型后就丢了。
|
||||||
|
if len(in.blocks) > 0 {
|
||||||
|
a.stageMediaDigests(a.captureBlockMedia(in.blocks, in.captureTool)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
noMemory := false
|
||||||
|
if v, ok := evt.Payload["no_memory"].(bool); ok {
|
||||||
|
noMemory = v
|
||||||
|
}
|
||||||
|
if !noMemory && a.io != nil {
|
||||||
|
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.NoMemory {
|
||||||
|
noMemory = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具提醒/中断(terminal_watch、timer 等)不是用户发言:
|
||||||
|
// 以 system 角色注入 LLM,且不写入用户对话履历。
|
||||||
|
isInterrupt, _ := evt.Payload["interrupt"].(bool)
|
||||||
|
a.interruptInput = isInterrupt
|
||||||
|
if isInterrupt {
|
||||||
|
noMemory = true
|
||||||
|
}
|
||||||
|
|
||||||
|
stageCtx := a.stageCtxFromInput(in.text, evt.Source, "")
|
||||||
|
stageCtx.Extra["input_source"] = evt.Source
|
||||||
|
stageCtx.Extra["output_channel"] = evt.OutputChannel
|
||||||
|
if len(in.blocks) > 0 {
|
||||||
|
stageCtx.Extra["media_blocks"] = in.blocks
|
||||||
|
stageCtx.Extra["media_type"] = in.mediaType
|
||||||
|
}
|
||||||
|
if noMemory {
|
||||||
|
stageCtx.NoMemory = true
|
||||||
|
}
|
||||||
|
a.injectSourceContext(stageCtx, evt)
|
||||||
|
|
||||||
|
if a.runStage(sdk.StageOnInput, stageCtx) {
|
||||||
|
a.emitResponse(evt, *stageCtx.Response)
|
||||||
|
return nil, terminalStageShortCircuit
|
||||||
|
}
|
||||||
|
|
||||||
|
input := stageCtx.RawMessage
|
||||||
|
|
||||||
|
// 计算层用的清洗文本(不改原文):通道 Cleaner 提取语义内容后用于向量化/提关键词
|
||||||
|
cleanInput := input
|
||||||
|
if a.io != nil {
|
||||||
|
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.Cleaner != nil {
|
||||||
|
cleanInput = chDef.Cleaner(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// upload_* 字段一并转发:webui 的 EventRawInput 订阅方靠它们还原附件卡片。
|
||||||
|
rawPayload := map[string]interface{}{"content": input, "source": evt.Source}
|
||||||
|
for _, k := range []string{"upload_url", "upload_type", "upload_size", "upload_name"} {
|
||||||
|
if v, ok := evt.Payload[k]; ok {
|
||||||
|
rawPayload[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.publishEvent(events.EventRawInput, rawPayload)
|
||||||
|
|
||||||
|
archived := a.pruneOnInput(evt, cleanInput)
|
||||||
|
if archived > 0 {
|
||||||
|
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本轮 inputch(处理表按它记账)+ contextfull 检测(只有驻留子设了钩子)。
|
||||||
|
a.tableMu.Lock()
|
||||||
|
a.currentInputch = outputChannelOf(evt)
|
||||||
|
a.tableMu.Unlock()
|
||||||
|
|
||||||
|
if !isInterrupt {
|
||||||
|
a.context.Append(ContextEvent{
|
||||||
|
Timestamp: start,
|
||||||
|
Source: evt.Source,
|
||||||
|
Input: input,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
f := a.newTaskFrame(input, stageCtx)
|
||||||
|
f.Evt = evt
|
||||||
|
f.CleanInput = cleanInput
|
||||||
|
f.IsInterrupt = isInterrupt
|
||||||
|
f.StartedAt = start
|
||||||
|
// 通道记进帧:恢复时用它把 agent 级字段改回来(见 TaskFrame.OutputChannel)。
|
||||||
|
f.OutputChannel = outputChannelOf(evt)
|
||||||
|
return f, terminalNone
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishInputTask 执行 processInput 的后半段(日志、上下文提交、回执、记忆候选)。
|
||||||
|
//
|
||||||
|
// 只在任务真正结束时调用一次——这正是不变量 I5(每任务恰一次终态)的落点。
|
||||||
|
func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) {
|
||||||
|
evt := f.Evt
|
||||||
|
|
||||||
|
// pendingMedia 是「本轮」语义:任务结束即清(挂起时保留,见 runInputTask)。
|
||||||
|
if evt != nil && (evt.Type == "image" || evt.Type == "audio") {
|
||||||
|
a.pendingMedia = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if out == outcomeFailed {
|
||||||
|
log.Printf("[agent] process %s error: %v", evt.Type, f.Err)
|
||||||
|
resp := fmt.Sprintf("处理错误: %v", f.Err)
|
||||||
|
a.emitResponse(evt, resp)
|
||||||
|
a.context.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Input: f.Input, Response: resp})
|
||||||
|
f.Terminal = terminalError
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// inputch 处理表:本轮**未主动写入**时由系统自动写(保证每轮必有记录)。
|
||||||
|
// 只有驻留子会用到(根 agent 的 children 为 0 时这只是几个空操作)。
|
||||||
|
a.autoRecordInputch(f)
|
||||||
|
|
||||||
|
elapsed := time.Since(f.StartedAt)
|
||||||
|
log.Printf("[agent] %s from %s → response (%dms, tools=%v)",
|
||||||
|
evt.Type, evt.Source, elapsed.Milliseconds(), f.ToolsUsed)
|
||||||
|
|
||||||
|
// 本轮捕获的媒体一起挂到这条事件上:用户上传的、插件注入的,以及模型调
|
||||||
|
// multimodal_see_picture / see_video 时经 SetToolBlocks 注入的。
|
||||||
|
turnEvt := ContextEvent{
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Source: "agent",
|
||||||
|
Input: f.CleanInput,
|
||||||
|
Response: f.Response,
|
||||||
|
ToolsUsed: f.ToolsUsed,
|
||||||
|
ToolResults: f.ToolResults,
|
||||||
|
}
|
||||||
|
a.bindEventMedia(&turnEvt, a.drainMediaDigests())
|
||||||
|
a.context.Append(turnEvt)
|
||||||
|
|
||||||
|
a.emitResponse(evt, f.Response)
|
||||||
|
|
||||||
|
if !f.StageCtx.NoMemory {
|
||||||
|
a.emitMemoryCandidate(evt.Source, f.CleanInput, f.Response, f.ToolResults, f.ToolsUsed)
|
||||||
|
}
|
||||||
|
f.Terminal = terminalOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// step 执行恰好一个 step。
|
||||||
|
func (a *Agent) step(f *TaskFrame) stepOutcome {
|
||||||
|
switch f.Step {
|
||||||
|
case StepPrepare:
|
||||||
|
return a.stepPrepare(f)
|
||||||
|
case StepLLM:
|
||||||
|
return a.stepLLM(f)
|
||||||
|
case StepToolBegin:
|
||||||
|
return a.stepToolBegin(f)
|
||||||
|
case StepToolExec:
|
||||||
|
return a.stepToolExec(f)
|
||||||
|
case StepToolAfter:
|
||||||
|
return a.stepToolAfter(f)
|
||||||
|
case StepTurnEnd:
|
||||||
|
return a.stepTurnEnd(f)
|
||||||
|
default:
|
||||||
|
f.Err = fmt.Errorf("agent: unknown task step %d", f.Step)
|
||||||
|
return outcomeFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepPrepare 构建本轮任务的初始帧。
|
||||||
|
func (a *Agent) stepPrepare(f *TaskFrame) stepOutcome {
|
||||||
|
budget := ComputeTokenBudget(a.provider, a.systemPrompt)
|
||||||
|
|
||||||
|
memContext := a.buildMemoryContext(f.Input, budget.MemoryTokens)
|
||||||
|
sysPrompt := a.buildSystemPrompt(memContext, f.Input)
|
||||||
|
f.Tools = a.buildToolDefs()
|
||||||
|
|
||||||
|
f.Msgs = a.buildMessages(sysPrompt, f.Input, a.contextTokenBudget(budget))
|
||||||
|
// 工具提醒(interrupt):以 system 角色注入,不让模型误认为用户发言
|
||||||
|
if a.interruptInput {
|
||||||
|
last := f.Msgs[len(f.Msgs)-1]
|
||||||
|
last.Role = "system"
|
||||||
|
last.Content = "[中断消息] " + last.Content
|
||||||
|
f.Msgs[len(f.Msgs)-1] = last
|
||||||
|
a.interruptInput = false
|
||||||
|
}
|
||||||
|
if blocks, ok := f.StageCtx.Extra["media_blocks"].([]agentAPI.ContentBlock); ok && len(blocks) > 0 {
|
||||||
|
if len(f.Msgs) > 0 {
|
||||||
|
f.Msgs[len(f.Msgs)-1].Blocks = blocks
|
||||||
|
}
|
||||||
|
f.InputBlocks = blocks
|
||||||
|
}
|
||||||
|
// 基础前缀到此为止(system + timeline + 用户输入);其后的 Stage 上下文
|
||||||
|
// 与工具轮产物都属于“本任务自己的现场”,恢复时要接回重建后的前缀之上。
|
||||||
|
f.PrefixLen = len(f.Msgs)
|
||||||
|
|
||||||
|
log.Printf("[agent] tool call loop start, max_ctx=%d target=%d fixed=%d mem=%d ctx=%d %d tools, %d events, personality=%t, docs=%d",
|
||||||
|
budget.MaxContext, budget.TargetUsage, budget.FixedTokens, budget.MemoryTokens, budget.ContextTokens,
|
||||||
|
len(f.Tools), a.context.Len(),
|
||||||
|
a.personality != nil && a.personality.Content != "",
|
||||||
|
a.docStoreSize())
|
||||||
|
|
||||||
|
if a.runStage(sdk.StagePreAction, f.StageCtx) {
|
||||||
|
f.Response = *f.StageCtx.Response
|
||||||
|
return outcomeDone
|
||||||
|
}
|
||||||
|
if len(f.StageCtx.ContextMsgs) > 0 {
|
||||||
|
for _, m := range f.StageCtx.ContextMsgs {
|
||||||
|
role, _ := m["role"].(string)
|
||||||
|
content, _ := m["content"].(string)
|
||||||
|
if role != "" {
|
||||||
|
f.Msgs = append(f.Msgs, agentAPI.Message{Role: role, Content: content})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上下文占满检测:此刻 f.Msgs 已建好(含 system + timeline + 本轮输入)。
|
||||||
|
// 只有驻留子设了 onContextFull ⇒ 对根 agent 是 no-op。
|
||||||
|
a.checkContextFull(f)
|
||||||
|
|
||||||
|
f.Step = StepLLM
|
||||||
|
return outcomeContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepLLM 是轮次顶部与 LLM 调用。
|
||||||
|
//
|
||||||
|
// 取消(context.Canceled 且 agent 未退出)时**留在本 step 并 Turn++**——等价于
|
||||||
|
// 原实现的 `continue`:重新排空中断、补占位、重新请求。抢占挂起将在 M3 从这里接管。
|
||||||
|
func (a *Agent) stepLLM(f *TaskFrame) stepOutcome {
|
||||||
|
// zen 兼容网关要求请求的最后一条消息必须是 user(thinking 续写模式校验),
|
||||||
|
// 工具轮产出的 tool/assistant 消息作结尾会被 400 拒绝,故补一条 user 占位。
|
||||||
|
f.Msgs = dropContinuationPlaceholders(f.Msgs)
|
||||||
|
if last := f.Msgs[len(f.Msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
|
||||||
|
f.Msgs = append(f.Msgs, agentAPI.Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: continuationFor(f.LastBatchReplyOnly),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &agentAPI.CompletionRequest{
|
||||||
|
Messages: f.Msgs,
|
||||||
|
MaxTokens: 4096,
|
||||||
|
Tools: f.Tools,
|
||||||
|
ToolChoice: "auto",
|
||||||
|
DisableThinking: !a.thinkingEnabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
providers := a.resolveProviders(req)
|
||||||
|
resp, llmErr := a.callLLMWithFallback(req, providers, f.OutputChannel)
|
||||||
|
|
||||||
|
if llmErr != nil {
|
||||||
|
if errors.Is(llmErr, context.Canceled) && a.ctx.Err() == nil {
|
||||||
|
if f.OutputChannel == channelConsolidation {
|
||||||
|
f.Err = fmt.Errorf("interrupted by user input")
|
||||||
|
return outcomeFailed
|
||||||
|
}
|
||||||
|
f.Turn++
|
||||||
|
return outcomeContinue // 重跑 StepLLM
|
||||||
|
}
|
||||||
|
f.Err = fmt.Errorf("all %d providers failed, last error: %w", len(providers), llmErr)
|
||||||
|
return outcomeFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
f.StageCtx.LLMText = resp.Content
|
||||||
|
f.StageCtx.ReasoningContent = resp.ReasoningContent
|
||||||
|
f.StageCtx.TokenUsage = map[string]int{
|
||||||
|
"prompt_tokens": resp.TokenUsage.Prompt,
|
||||||
|
"completion_tokens": resp.TokenUsage.Completion,
|
||||||
|
"total_tokens": resp.TokenUsage.Total,
|
||||||
|
}
|
||||||
|
f.StageCtx.ToolCalls = convertToolCalls(resp.ToolCalls)
|
||||||
|
for i := range f.StageCtx.ToolCalls {
|
||||||
|
if f.StageCtx.ToolCalls[i].Plugin == "" {
|
||||||
|
f.StageCtx.ToolCalls[i].Plugin = a.resolveToolPlugin(f.StageCtx.ToolCalls[i].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.runStage(sdk.StagePostAction, f.StageCtx) {
|
||||||
|
f.Response = *f.StageCtx.Response
|
||||||
|
return outcomeDone
|
||||||
|
}
|
||||||
|
resp.Content = f.StageCtx.LLMText
|
||||||
|
resp.ToolCalls = convertBackToolCalls(f.StageCtx.ToolCalls)
|
||||||
|
|
||||||
|
chainPayload := map[string]interface{}{
|
||||||
|
"content": resp.Content,
|
||||||
|
"reasoning": resp.ReasoningContent,
|
||||||
|
"tool_calls": resp.ToolCalls,
|
||||||
|
"phase": "intermediate",
|
||||||
|
"turn": f.Turn,
|
||||||
|
}
|
||||||
|
if resp.TokenUsage.Total > 0 {
|
||||||
|
chainPayload["usage"] = map[string]int{
|
||||||
|
"prompt": resp.TokenUsage.Prompt,
|
||||||
|
"completion": resp.TokenUsage.Completion,
|
||||||
|
"total": resp.TokenUsage.Total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.publishEvent(events.EventAgentLLMChain, chainPayload)
|
||||||
|
|
||||||
|
if resp.ReasoningContent != "" {
|
||||||
|
a.publishEvent(events.EventReasoning, map[string]interface{}{
|
||||||
|
"content": resp.ReasoningContent,
|
||||||
|
"channel": f.OutputChannel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.ToolCalls) == 0 {
|
||||||
|
f.Response = resp.Content
|
||||||
|
return outcomeDone
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本批是否全部是输出通道发送(=模型刚交付了给用户的回复)。
|
||||||
|
// 必须在执行前判定:执行过程中的中断/拒绝分支会 continue/break,放在循环里统计会漏。
|
||||||
|
f.Resp = resp
|
||||||
|
f.ReplyOnly = true
|
||||||
|
for _, tc := range resp.ToolCalls {
|
||||||
|
if !isOutputDeliveryTool(tc.Name) {
|
||||||
|
f.ReplyOnly = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.ContentOnce = true
|
||||||
|
f.PendingTools = resp.ToolCalls
|
||||||
|
f.ToolIdx = 0
|
||||||
|
f.Step = StepToolBegin
|
||||||
|
return outcomeContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepToolBegin 取本批下一个工具;批已耗尽或发生中断则进入收尾。
|
||||||
|
func (a *Agent) stepToolBegin(f *TaskFrame) stepOutcome {
|
||||||
|
if f.ToolIdx >= len(f.PendingTools) {
|
||||||
|
f.Step = StepTurnEnd
|
||||||
|
return outcomeContinue
|
||||||
|
}
|
||||||
|
tc := f.PendingTools[f.ToolIdx]
|
||||||
|
|
||||||
|
f.ToolsUsed = append(f.ToolsUsed, tc.Name)
|
||||||
|
pluginName := a.resolveToolPlugin(tc.Name)
|
||||||
|
log.Printf("[agent] executing tool: %s (plugin=%s, id=%s)", tc.Name, pluginName, tc.ID)
|
||||||
|
if tc.RawArguments != "" {
|
||||||
|
log.Printf("[agent] tool %s raw_arguments: %s", tc.Name, truncateStr(tc.RawArguments, 300))
|
||||||
|
}
|
||||||
|
|
||||||
|
sdkTC := sdk.ToolCall{ID: tc.ID, Name: tc.Name, Plugin: pluginName, Arguments: tc.Arguments}
|
||||||
|
f.StageCtx.ToolCalls = []sdk.ToolCall{sdkTC}
|
||||||
|
f.StageCtx.ToolResults = nil
|
||||||
|
if a.runStage(sdk.StageBeforeToolcall, f.StageCtx) {
|
||||||
|
result := fmt.Sprintf("工具 %s 已被插件拒绝", tc.Name)
|
||||||
|
f.Msgs = append(f.Msgs, agentAPI.Message{Role: "assistant", ToolCalls: []agentAPI.ToolCall{tc}})
|
||||||
|
f.Msgs = append(f.Msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
|
||||||
|
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||||
|
"tool": tc.Name,
|
||||||
|
"plugin": pluginName,
|
||||||
|
"args": tc.Arguments,
|
||||||
|
"result": result,
|
||||||
|
"status": "denied",
|
||||||
|
"channel": f.OutputChannel,
|
||||||
|
})
|
||||||
|
f.ToolIdx++
|
||||||
|
return outcomeContinue
|
||||||
|
}
|
||||||
|
tc.Arguments = f.StageCtx.ToolCalls[0].Arguments
|
||||||
|
|
||||||
|
if pluginName != "" && !a.pluginHealth.isHealthy(pluginName) {
|
||||||
|
result := fmt.Sprintf("插件 %s 处于崩溃状态,已跳过执行,等待自动恢复重载", pluginName)
|
||||||
|
log.Printf("[agent] skip tool %s: plugin %s unhealthy", tc.Name, pluginName)
|
||||||
|
f.Msgs = append(f.Msgs, agentAPI.Message{Role: "assistant", ToolCalls: []agentAPI.ToolCall{tc}})
|
||||||
|
f.Msgs = append(f.Msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
|
||||||
|
f.ToolIdx++
|
||||||
|
return outcomeContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
f.CurTool = tc
|
||||||
|
f.CurToolPlugin = pluginName
|
||||||
|
f.Step = StepToolExec
|
||||||
|
return outcomeContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepToolExec 执行工具。**临界区**:见设计文档 §4.3。
|
||||||
|
func (a *Agent) stepToolExec(f *TaskFrame) stepOutcome {
|
||||||
|
result := a.executeToolCall(f.CurTool, f.OutputChannel)
|
||||||
|
f.CurResult = result
|
||||||
|
f.ToolResults = append(f.ToolResults, ToolResultItem{Name: f.CurTool.Name, Output: result})
|
||||||
|
log.Printf("[agent] tool %s result: %s", f.CurTool.Name, truncateStr(result, 100))
|
||||||
|
|
||||||
|
f.StageCtx.ToolResults = []sdk.ToolResult{{
|
||||||
|
CallID: f.CurTool.ID, Name: f.CurTool.Name, Plugin: f.CurToolPlugin,
|
||||||
|
Success: true, Result: result,
|
||||||
|
}}
|
||||||
|
f.Step = StepToolAfter
|
||||||
|
return outcomeContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepToolAfter 是工具执行后的全部后处理(阶段、裁剪、消息与事件)。
|
||||||
|
func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome {
|
||||||
|
tc := f.CurTool
|
||||||
|
pluginName := f.CurToolPlugin
|
||||||
|
result := f.CurResult
|
||||||
|
|
||||||
|
a.runStage(sdk.StageAfterToolcall, f.StageCtx)
|
||||||
|
if len(f.StageCtx.ToolResults) > 0 {
|
||||||
|
if r, ok := f.StageCtx.ToolResults[0].Result.(string); ok {
|
||||||
|
result = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ContextPolicy: prune 工具调用后执行上下文裁剪(§13.8)
|
||||||
|
if def := a.stageHost.ToolDef(tc.Name); def != nil && def.ContextPolicy == "prune" {
|
||||||
|
if a.context != nil {
|
||||||
|
topK := a.maxContextSize - 1
|
||||||
|
if topK < 1 {
|
||||||
|
topK = 1
|
||||||
|
}
|
||||||
|
// 查询向量取**清洗后**的有效内容,否则噪声(ANSI/base64/JSON 包装)
|
||||||
|
// 会把相关性打分带偏,裁掉本该保留的事件。
|
||||||
|
a.context.Prune(a.toolOutputForQuery(tc.Name, result), topK, a.docStore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msgContent := ""
|
||||||
|
if f.ContentOnce {
|
||||||
|
msgContent = f.Resp.Content
|
||||||
|
f.ContentOnce = false
|
||||||
|
}
|
||||||
|
f.Msgs = append(f.Msgs, agentAPI.Message{
|
||||||
|
Role: "assistant", Content: msgContent,
|
||||||
|
ReasoningContent: f.Resp.ReasoningContent,
|
||||||
|
ToolCalls: []agentAPI.ToolCall{tc},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 多模态工具结果:插件通过 SDK.SetToolBlocks 注入 image_url/audio_url block。
|
||||||
|
//
|
||||||
|
// 媒体不挂在 tool message 上,而是另起一条紧随其后的 user message——
|
||||||
|
// 这也是插件文案一直在说的「注入后续对话」。
|
||||||
|
// 为何不能挂 tool message:同一张图、同一模型、三轮实测——
|
||||||
|
// 图在 user message → 3/3 读到
|
||||||
|
// 图在 tool message → 0/3(模型答「没能读到这张图」)
|
||||||
|
// tool 纯文本 + 后接 user → 3/3 读到
|
||||||
|
// tool message 那轮 prompt_tokens 反而更高(7967 vs 7089),base64 确实
|
||||||
|
// 进了上游,但 role=tool 上的多模态 content 数组不被当作可视内容。
|
||||||
|
//
|
||||||
|
// 主模型不支持该模态时更不能直接塞:网关会把 image_url 静默剥离后仍
|
||||||
|
// 返回 200,模型回答「我没有看到图片」而内核以为注入成功。改走回退链。
|
||||||
|
toolMsg := agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result}
|
||||||
|
var mediaMsg *agentAPI.Message
|
||||||
|
if rawBlocks := a.io.ConsumeToolBlocks(); len(rawBlocks) > 0 {
|
||||||
|
var blocks []agentAPI.ContentBlock
|
||||||
|
for _, b := range rawBlocks {
|
||||||
|
if cb, ok := b.(pubsdk.ContentBlock); ok {
|
||||||
|
// 跨包类型拷贝(pubsdk.ContentBlock → agentAPI.ContentBlock)
|
||||||
|
block := agentAPI.ContentBlock{Type: cb.Type, Text: cb.Text}
|
||||||
|
if cb.ImageURL != nil {
|
||||||
|
block.ImageURL = &agentAPI.ImageURL{URL: cb.ImageURL.URL, Detail: cb.ImageURL.Detail}
|
||||||
|
}
|
||||||
|
if cb.AudioURL != nil {
|
||||||
|
block.AudioURL = &agentAPI.AudioURL{URL: cb.AudioURL.URL}
|
||||||
|
}
|
||||||
|
blocks = append(blocks, block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(blocks) > 0 {
|
||||||
|
// 先落进 CAS:无论下面走直视还是回退转写,媒体本体都该进记忆。
|
||||||
|
a.stageMediaDigests(a.captureBlockMedia(blocks, tc.Name)...)
|
||||||
|
|
||||||
|
if native, fallbackText := a.prepareToolBlocks(blocks); len(native) > 0 {
|
||||||
|
// 能直视:另起一条 user message 承载媒体,并补一句来源说明。
|
||||||
|
mediaBlocks := append([]agentAPI.ContentBlock{{
|
||||||
|
Type: "text",
|
||||||
|
Text: fmt.Sprintf("[以下是 %s 注入的媒体内容]", tc.Name),
|
||||||
|
}}, native...)
|
||||||
|
mediaMsg = &agentAPI.Message{Role: "user", Blocks: mediaBlocks}
|
||||||
|
} else if fallbackText != "" {
|
||||||
|
toolMsg.Content = result + "\n\n" + fallbackText
|
||||||
|
result = toolMsg.Content
|
||||||
|
if len(f.ToolResults) > 0 {
|
||||||
|
f.ToolResults[len(f.ToolResults)-1].Output = result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.Msgs = append(f.Msgs, toolMsg)
|
||||||
|
if mediaMsg != nil {
|
||||||
|
// 必须紧跟在 toolMsg 之后:中间插入其他消息会让 tool_call_id 配对断开。
|
||||||
|
f.Msgs = append(f.Msgs, *mediaMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||||
|
"tool": tc.Name,
|
||||||
|
"plugin": pluginName,
|
||||||
|
"args": tc.Arguments,
|
||||||
|
"result": result,
|
||||||
|
"status": "ok",
|
||||||
|
"channel": f.OutputChannel,
|
||||||
|
})
|
||||||
|
|
||||||
|
f.ToolIdx++
|
||||||
|
f.Step = StepToolBegin
|
||||||
|
return outcomeContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepTurnEnd 收尾本批并进入下一轮。
|
||||||
|
func (a *Agent) stepTurnEnd(f *TaskFrame) stepOutcome {
|
||||||
|
// 供下一轮顶部选择补位文案。
|
||||||
|
f.LastBatchReplyOnly = f.ReplyOnly
|
||||||
|
f.Turn++
|
||||||
|
f.Step = StepLLM
|
||||||
|
return outcomeContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveProviders 按请求模型解析候选 provider(保持原语义)。
|
||||||
|
func (a *Agent) resolveProviders(req *agentAPI.CompletionRequest) []agentAPI.Provider {
|
||||||
|
var providers []agentAPI.Provider
|
||||||
|
if a.providerManager != nil {
|
||||||
|
var allProviders []agentAPI.Provider
|
||||||
|
if req.Model != "" && !strings.EqualFold(req.Model, "AUTO") {
|
||||||
|
allProviders = a.providerManager.ResolveForModel(req.Model)
|
||||||
|
} else {
|
||||||
|
allProviders = a.providerManager.OrderedProviders()
|
||||||
|
}
|
||||||
|
providers = make([]agentAPI.Provider, 0, len(allProviders))
|
||||||
|
for _, p := range allProviders {
|
||||||
|
if a.providerManager.IsAvailable(p.Name()) {
|
||||||
|
providers = append(providers, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(providers) == 0 {
|
||||||
|
providers = []agentAPI.Provider{a.provider}
|
||||||
|
}
|
||||||
|
return providers
|
||||||
|
}
|
||||||
|
|
||||||
|
// callLLMWithFallback 在候选 provider 间回退,并把同源瞬时错误重试一次。
|
||||||
|
// 逐行等价于原 process() 内的双层循环。
|
||||||
|
func (a *Agent) callLLMWithFallback(req *agentAPI.CompletionRequest, providers []agentAPI.Provider, channel string) (*agentAPI.CompletionResponse, error) {
|
||||||
|
var resp *agentAPI.CompletionResponse
|
||||||
|
var llmErr error
|
||||||
|
|
||||||
|
for pi, fbProvider := range providers {
|
||||||
|
if pi > 0 {
|
||||||
|
log.Printf("[agent] LLM fallback: trying provider %q (fallback #%d/%d)",
|
||||||
|
fbProvider.Name(), pi, len(providers)-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同源瞬时错误重试:网关瞬断(502/503/504/429/网络抖动)通常秒级恢复,
|
||||||
|
// 直接跳下一个 provider(或直接报错)会丢掉本可成功的请求。
|
||||||
|
// 凭证错误(401/403)与用户中断不重试。
|
||||||
|
const maxAttempts = 2
|
||||||
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||||
|
if attempt > 1 {
|
||||||
|
log.Printf("[agent] provider %q transient failure, retry %d/%d in 2s: %v",
|
||||||
|
fbProvider.Name(), attempt, maxAttempts, llmErr)
|
||||||
|
select {
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
case <-a.ctx.Done():
|
||||||
|
llmErr = a.ctx.Err()
|
||||||
|
}
|
||||||
|
if llmErr == nil || errors.Is(llmErr, context.Canceled) || errors.Is(llmErr, context.DeadlineExceeded) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fCtx, fCancel := context.WithCancel(a.ctx)
|
||||||
|
a.llmMu.Lock()
|
||||||
|
a.cancelLLM = fCancel
|
||||||
|
a.llmMu.Unlock()
|
||||||
|
|
||||||
|
resp, llmErr = chatStreamWithFallback(fCtx, fbProvider, req, a, channel)
|
||||||
|
|
||||||
|
a.llmMu.Lock()
|
||||||
|
a.cancelLLM = nil
|
||||||
|
a.llmMu.Unlock()
|
||||||
|
fCancel()
|
||||||
|
|
||||||
|
if llmErr == nil {
|
||||||
|
a.providerManager.ResetAvailability(fbProvider.Name())
|
||||||
|
if fbProvider != a.provider {
|
||||||
|
a.provider = fbProvider
|
||||||
|
log.Printf("[agent] switched active provider to %q after fallback", fbProvider.Name())
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户中断:立即终止,不重试也不换 provider
|
||||||
|
if errors.Is(llmErr, context.Canceled) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// 凭证错误:重试无意义,跳出重试循环进入 provider 标记/切换
|
||||||
|
var pe *agentAPI.ProviderError
|
||||||
|
if errors.As(llmErr, &pe) && (pe.StatusCode == 401 || pe.StatusCode == 403) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// 其余错误(含 5xx/429/网络):还有重试机会则继续,否则跳出
|
||||||
|
}
|
||||||
|
|
||||||
|
if llmErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if errors.Is(llmErr, context.Canceled) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var pe *agentAPI.ProviderError
|
||||||
|
if errors.As(llmErr, &pe) && (pe.StatusCode == 401 || pe.StatusCode == 403) {
|
||||||
|
a.providerManager.ReportStatus(fbProvider.Name(), pe.StatusCode)
|
||||||
|
log.Printf("[agent] provider %q marked unavailable (HTTP %d)", fbProvider.Name(), pe.StatusCode)
|
||||||
|
} else {
|
||||||
|
a.providerManager.MarkUnavailable(fbProvider.Name())
|
||||||
|
}
|
||||||
|
log.Printf("[agent] provider %q failed: %v", fbProvider.Name(), llmErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, llmErr
|
||||||
|
}
|
||||||
211
internal/agent/core/task_lifecycle_test.go
Normal file
211
internal/agent/core/task_lifecycle_test.go
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// M3a 验收测试:任务生命周期(prepare → run → finish)与「每任务恰一次终态」。
|
||||||
|
//
|
||||||
|
// 设计依据 docs/zh/input-scheduler-design.md §11.3(X2/X3/X4 的 M3a 形态):
|
||||||
|
// 帧覆盖全生命周期后,提交(context.Append)与回执(emitResponse)只能在
|
||||||
|
// finish 段发生一次——挂起不会重复提交。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||||
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newLifecycleAgent(t *testing.T, sp agentAPI.Provider, bus *events.Bus, sh *StageHost) *Agent {
|
||||||
|
t.Helper()
|
||||||
|
return New(AgentConfig{
|
||||||
|
ID: "lifecycle",
|
||||||
|
Provider: sp,
|
||||||
|
ProviderManager: agentAPI.NewProviderManager(),
|
||||||
|
IO: agentIO.NewIOManager(),
|
||||||
|
StageHost: sh,
|
||||||
|
EventBus: bus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func textEvent(source, content string) (*agentIO.InputEvent, chan *agentIO.OutputEvent) {
|
||||||
|
ch := make(chan *agentIO.OutputEvent, 1)
|
||||||
|
return &agentIO.InputEvent{
|
||||||
|
RequestID: "req-1",
|
||||||
|
Source: source,
|
||||||
|
Type: "text",
|
||||||
|
Payload: map[string]interface{}{"content": content},
|
||||||
|
OutputChannel: source,
|
||||||
|
ResponseCh: ch,
|
||||||
|
}, ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// X3(M3a 形态):正常任务在 finish 段**恰好**提交一次并回执一次。
|
||||||
|
func TestLifecycle_NormalCommitsOnceAndReplies(t *testing.T) {
|
||||||
|
bus := events.NewBus()
|
||||||
|
var outputs, rawInputs int
|
||||||
|
bus.Subscribe(events.EventAgentOutput, func(*events.Event) { outputs++ })
|
||||||
|
bus.Subscribe(events.EventRawInput, func(*events.Event) { rawInputs++ })
|
||||||
|
|
||||||
|
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{{Content: "答复"}}}
|
||||||
|
a := newLifecycleAgent(t, sp, bus, NewStageHost())
|
||||||
|
|
||||||
|
evt, respCh := textEvent("cli", "你好")
|
||||||
|
if _, out := a.runInputTask(evt); out != outcomeDone {
|
||||||
|
t.Fatalf("runInputTask=%v,期望 outcomeDone", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case r := <-respCh:
|
||||||
|
if got, _ := r.Payload["content"].(string); got != "答复" {
|
||||||
|
t.Fatalf("回执内容=%q,期望 答复", got)
|
||||||
|
}
|
||||||
|
if !r.Done {
|
||||||
|
t.Fatal("回执必须带 Done=true")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatal("同步回执缺失:finish 段必须写 ResponseCh")
|
||||||
|
}
|
||||||
|
|
||||||
|
if outputs != 1 {
|
||||||
|
t.Fatalf("agent_output 事件=%d,期望恰好 1(每任务一次终态)", outputs)
|
||||||
|
}
|
||||||
|
if rawInputs != 1 {
|
||||||
|
t.Fatalf("raw_input 事件=%d,期望 1", rawInputs)
|
||||||
|
}
|
||||||
|
if a.context.Len() != 2 {
|
||||||
|
t.Fatalf("上下文事件=%d,期望 2(输入事件 + 本轮事件)", a.context.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// X2:被去重的输入以 skipped 终态结束——不提交、不回执、不发输出事件。
|
||||||
|
func TestLifecycle_DuplicateSkippedHasTerminal(t *testing.T) {
|
||||||
|
bus := events.NewBus()
|
||||||
|
outputs := 0
|
||||||
|
bus.Subscribe(events.EventAgentOutput, func(*events.Event) { outputs++ })
|
||||||
|
|
||||||
|
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
|
||||||
|
{Content: "第一次"}, {Content: "第二次"},
|
||||||
|
}}
|
||||||
|
a := newLifecycleAgent(t, sp, bus, NewStageHost())
|
||||||
|
|
||||||
|
e1, _ := textEvent("webui", "同样的消息")
|
||||||
|
if _, out := a.runInputTask(e1); out != outcomeDone {
|
||||||
|
t.Fatalf("首次输入=%v,期望 outcomeDone", out)
|
||||||
|
}
|
||||||
|
after1, outputs1 := a.context.Len(), outputs
|
||||||
|
|
||||||
|
e2, ch2 := textEvent("webui", "同样的消息")
|
||||||
|
if _, out := a.runInputTask(e2); out != outcomeDone {
|
||||||
|
t.Fatalf("去重输入应正常返回(不挂起),实际 %v", out)
|
||||||
|
}
|
||||||
|
if a.context.Len() != after1 {
|
||||||
|
t.Fatalf("去重命中不得提交上下文:%d → %d", after1, a.context.Len())
|
||||||
|
}
|
||||||
|
if len(ch2) != 1 {
|
||||||
|
t.Fatal("去重命中必须回一个 skipped 终态,否则同步调用方永久挂起")
|
||||||
|
}
|
||||||
|
r := <-ch2
|
||||||
|
if skipped, _ := r.Payload["skipped"].(bool); !skipped {
|
||||||
|
t.Fatalf("去重回执必须带 skipped=true,实际 %+v", r.Payload)
|
||||||
|
}
|
||||||
|
if reason, _ := r.Payload["reason"].(string); reason != "duplicate" {
|
||||||
|
t.Fatalf("reason=%q,期望 duplicate", reason)
|
||||||
|
}
|
||||||
|
if outputs != outputs1 {
|
||||||
|
t.Fatalf("去重命中不得发输出事件:%d → %d", outputs1, outputs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 被 on_input 阶段短路:回执阶段给的响应,且不提交上下文(与原实现一致)。
|
||||||
|
func TestLifecycle_OnInputShortCircuit(t *testing.T) {
|
||||||
|
sh := NewStageHost()
|
||||||
|
reply := "被插件短路"
|
||||||
|
sh.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
|
||||||
|
ctx.Response = &reply
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
sp := &scriptProvider{} // 不应被调用到
|
||||||
|
a := newLifecycleAgent(t, sp, events.NewBus(), sh)
|
||||||
|
|
||||||
|
evt, respCh := textEvent("cli", "任意")
|
||||||
|
if _, out := a.runInputTask(evt); out != outcomeDone {
|
||||||
|
t.Fatalf("短路任务=%v,期望 outcomeDone", out)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case r := <-respCh:
|
||||||
|
if got, _ := r.Payload["content"].(string); got != reply {
|
||||||
|
t.Fatalf("短路响应=%q,期望 %q", got, reply)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatal("短路路径必须回执")
|
||||||
|
}
|
||||||
|
if a.context.Len() != 0 {
|
||||||
|
t.Fatalf("短路路径不得提交上下文,实际 %d 条", a.context.Len())
|
||||||
|
}
|
||||||
|
if len(sp.reqs) != 0 {
|
||||||
|
t.Fatal("短路路径不得调用 LLM")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误路径:以 error 终态结束,回执错误文本,且提交的是**错误事件**(无 turn 事件)。
|
||||||
|
func TestLifecycle_ErrorPathTerminal(t *testing.T) {
|
||||||
|
bus := events.NewBus()
|
||||||
|
outputs := 0
|
||||||
|
bus.Subscribe(events.EventAgentOutput, func(*events.Event) { outputs++ })
|
||||||
|
|
||||||
|
sp := &scriptProvider{err: &agentAPI.ProviderError{StatusCode: 401, Message: "bad key"}}
|
||||||
|
a := newLifecycleAgent(t, sp, bus, NewStageHost())
|
||||||
|
|
||||||
|
evt, respCh := textEvent("cli", "会失败")
|
||||||
|
if _, out := a.runInputTask(evt); out != outcomeFailed {
|
||||||
|
t.Fatalf("runInputTask=%v,期望 outcomeFailed", out)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case r := <-respCh:
|
||||||
|
got, _ := r.Payload["content"].(string)
|
||||||
|
if !strings.HasPrefix(got, "处理错误:") {
|
||||||
|
t.Fatalf("错误回执=%q,期望以 处理错误: 开头", got)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatal("错误路径必须回执(否则同步调用方永久挂起)")
|
||||||
|
}
|
||||||
|
if outputs != 1 {
|
||||||
|
t.Fatalf("错误路径的 agent_output 事件=%d,期望 1", outputs)
|
||||||
|
}
|
||||||
|
// 输入事件 + 错误事件 = 2;不得出现带 ToolsUsed 的 turn 事件。
|
||||||
|
if a.context.Len() != 2 {
|
||||||
|
t.Fatalf("错误路径上下文事件=%d,期望 2", a.context.Len())
|
||||||
|
}
|
||||||
|
recent := a.context.Recent(10)
|
||||||
|
last := recent[len(recent)-1]
|
||||||
|
if last.Response == "" {
|
||||||
|
t.Fatal("错误事件必须带 Response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// _consolidation_ 走记忆整理专用路径:不回执、不提交上下文。
|
||||||
|
func TestLifecycle_ConsolidationRouted(t *testing.T) {
|
||||||
|
bus := events.NewBus()
|
||||||
|
outputs := 0
|
||||||
|
bus.Subscribe(events.EventAgentOutput, func(*events.Event) { outputs++ })
|
||||||
|
|
||||||
|
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{{Content: "整理完毕"}}}
|
||||||
|
a := newLifecycleAgent(t, sp, bus, NewStageHost())
|
||||||
|
|
||||||
|
evt, respCh := textEvent("system", "整理任务")
|
||||||
|
evt.OutputChannel = channelConsolidation
|
||||||
|
if _, out := a.runInputTask(evt); out != outcomeDone {
|
||||||
|
t.Fatalf("consolidation=%v,期望 outcomeDone", out)
|
||||||
|
}
|
||||||
|
if len(respCh) != 0 {
|
||||||
|
t.Fatal("consolidation 路径不得回执")
|
||||||
|
}
|
||||||
|
if outputs != 0 {
|
||||||
|
t.Fatalf("consolidation 路径不得发输出事件,实际 %d", outputs)
|
||||||
|
}
|
||||||
|
if a.context.Len() != 0 {
|
||||||
|
t.Fatalf("consolidation 路径不得写用户上下文,实际 %d", a.context.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
132
internal/agent/core/task_terminal_test.go
Normal file
132
internal/agent/core/task_terminal_test.go
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// M6 验收测试:任务级回执与断链点统一为终态事件。
|
||||||
|
//
|
||||||
|
// 设计依据 docs/zh/input-scheduler-design.md §7(不变量 I5)、§11.3(X1–X4)。
|
||||||
|
//
|
||||||
|
// 问题背景:回执原先由全局 emitResponse 写(无任务归属),且 processInput 有多条
|
||||||
|
// 「提前 return 而不 emit」的路径(解析失败、去重、consolidation)——同步调用方
|
||||||
|
// 若不自带超时(cli、clawhubadapter)就会永久挂起。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// X1:回执按任务归属,中断的回执绝不投给被挂起的等待者。
|
||||||
|
func TestTerminal_TaskScopedReplyNotMisrouted(t *testing.T) {
|
||||||
|
sp := newPreemptProvider("intr-done", "low-done")
|
||||||
|
a := newPreemptAgent(t, sp)
|
||||||
|
|
||||||
|
lowEvt, lowCh := textEvent("qq", "低优先级任务")
|
||||||
|
lowTask := newInputTask(lowEvt)
|
||||||
|
if !a.sched.enqueue(lowTask) {
|
||||||
|
t.Fatal("入队失败")
|
||||||
|
}
|
||||||
|
lt, _, _ := a.sched.nextRef()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { a.executeNewTask(lt); close(done) }()
|
||||||
|
select {
|
||||||
|
case <-sp.entered:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("provider 未被调用")
|
||||||
|
}
|
||||||
|
|
||||||
|
intrEvt, intrCh := textEvent("cli", "紧急打断")
|
||||||
|
intrEvt.Payload["interrupt"] = true
|
||||||
|
if !a.sched.requestKernelPreempt(intrEvt) {
|
||||||
|
t.Fatal("内核 L4 应抢占排队任务")
|
||||||
|
}
|
||||||
|
a.cancelCurrentLLM()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("未挂起")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行中断任务 → 只应写它自己的回执通道。
|
||||||
|
it, _, k := a.sched.nextRef()
|
||||||
|
if k != nextImmediate {
|
||||||
|
t.Fatalf("应取到立即运行的中断,kind=%v", k)
|
||||||
|
}
|
||||||
|
a.executeNewTask(it)
|
||||||
|
if len(intrCh) != 1 {
|
||||||
|
t.Fatalf("中断任务应回执到自己的通道,实际 %d", len(intrCh))
|
||||||
|
}
|
||||||
|
if len(lowCh) != 0 {
|
||||||
|
t.Fatal("中断的回执绝不能被投给被挂起的等待者")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复并结束后,原任务才拿到自己的回执。
|
||||||
|
rt, rf, k2 := a.sched.nextRef()
|
||||||
|
if k2 != nextSuspended {
|
||||||
|
t.Fatalf("应恢复被抢占任务,kind=%v", k2)
|
||||||
|
}
|
||||||
|
a.resumeTask(rt, rf)
|
||||||
|
if len(lowCh) != 1 {
|
||||||
|
t.Fatalf("恢复任务结束后应恰好回执一次,实际 %d", len(lowCh))
|
||||||
|
}
|
||||||
|
if got, _ := (<-lowCh).Payload["content"].(string); got != "low-done" {
|
||||||
|
t.Fatalf("原任务回执内容=%q,期望 low-done", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// X2:空输入(解析失败)也必须有终态回执。
|
||||||
|
func TestTerminal_EmptyInputGetsSkippedReply(t *testing.T) {
|
||||||
|
a := newLifecycleAgent(t, &scriptProvider{}, nil, NewStageHost())
|
||||||
|
ch := make(chan *agentIO.OutputEvent, 1)
|
||||||
|
evt := &agentIO.InputEvent{
|
||||||
|
RequestID: "r-empty",
|
||||||
|
Source: "cli",
|
||||||
|
Type: "text",
|
||||||
|
Payload: map[string]interface{}{}, // 无 content,无媒体块
|
||||||
|
OutputChannel: "cli",
|
||||||
|
ResponseCh: ch,
|
||||||
|
}
|
||||||
|
if _, out := a.runInputTask(evt); out != outcomeDone {
|
||||||
|
t.Fatalf("空输入应正常返回,实际 %v", out)
|
||||||
|
}
|
||||||
|
if len(ch) != 1 {
|
||||||
|
t.Fatal("空输入必须回 skipped 终态")
|
||||||
|
}
|
||||||
|
if reason, _ := (<-ch).Payload["reason"].(string); reason != "empty_input" {
|
||||||
|
t.Fatalf("reason=%q,期望 empty_input", reason)
|
||||||
|
}
|
||||||
|
if a.context.Len() != 0 {
|
||||||
|
t.Fatal("空输入不得写入上下文")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// X4:无超时的同步调用方(cli / clawhubadapter)在断链路径上不再永久挂起。
|
||||||
|
//
|
||||||
|
// 这是回归判据:修复前 `InjectTextSync` 遇到去重命中会永久阻塞。
|
||||||
|
func TestTerminal_NoTimeoutSyncCallerDoesNotHang(t *testing.T) {
|
||||||
|
a := newLifecycleAgent(t, &scriptProvider{script: []*agentAPI.CompletionResponse{
|
||||||
|
{Content: "第一次"}, {Content: "第二次"},
|
||||||
|
}}, nil, NewStageHost())
|
||||||
|
|
||||||
|
// 第一次成功
|
||||||
|
e1, ch1 := textEvent("cli", "重复内容")
|
||||||
|
if _, out := a.runInputTask(e1); out != outcomeDone {
|
||||||
|
t.Fatalf("首次=%v", out)
|
||||||
|
}
|
||||||
|
if len(ch1) != 1 {
|
||||||
|
t.Fatal("首次应有回执")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二次(去重命中):模拟同步调用方阻塞等待——必须在 1s 内拿到终态。
|
||||||
|
e2, ch2 := textEvent("cli", "重复内容")
|
||||||
|
go a.runInputTask(e2)
|
||||||
|
select {
|
||||||
|
case r := <-ch2:
|
||||||
|
if skipped, _ := r.Payload["skipped"].(bool); !skipped {
|
||||||
|
t.Fatalf("应为 skipped 终态,实际 %+v", r.Payload)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("去重命中让同步调用方永久挂起(X4 回归)")
|
||||||
|
}
|
||||||
|
}
|
||||||
238
internal/agent/core/task_test.go
Normal file
238
internal/agent/core/task_test.go
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// M1 验收测试:状态机与 TaskFrame 的**行为等价性**。
|
||||||
|
//
|
||||||
|
// 设计依据 docs/zh/input-scheduler-design.md §11(R3 / X3 的 M1 形态):
|
||||||
|
// M1 不引入抢占,因此 R3 退化为「经状态机跑出的结果与脚本预期一致」;
|
||||||
|
// X3 在 M1 退化为「驱动循环必然以一次终态返回结束(不空转、不超步数)」。
|
||||||
|
//
|
||||||
|
// 抢占/挂起/恢复/优先级在 M3 起才有测试。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scriptProvider 按脚本依次返回 CompletionResponse。
|
||||||
|
//
|
||||||
|
// ChatStream 故意返回错误:驱动 chatStreamWithFallback 走非流式回退,
|
||||||
|
// 这样脚本就是「第 N 次调用返回第 N 个响应」,不依赖流式分片语义。
|
||||||
|
type scriptProvider struct {
|
||||||
|
script []*agentAPI.CompletionResponse
|
||||||
|
idx int
|
||||||
|
reqs []*agentAPI.CompletionRequest
|
||||||
|
// err 非空时 Chat 直接返回它(用于错误路径测试)。
|
||||||
|
// 配合 ProviderError(401) 可跳过 2s 瞬时重试,让测试保持快速。
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *scriptProvider) Name() string { return "script" }
|
||||||
|
func (s *scriptProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||||
|
s.reqs = append(s.reqs, req)
|
||||||
|
if s.err != nil {
|
||||||
|
return nil, s.err
|
||||||
|
}
|
||||||
|
if s.idx >= len(s.script) {
|
||||||
|
return &agentAPI.CompletionResponse{Content: ""}, nil
|
||||||
|
}
|
||||||
|
r := s.script[s.idx]
|
||||||
|
s.idx++
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
func (s *scriptProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||||
|
return nil, errors.New("script provider: streaming disabled")
|
||||||
|
}
|
||||||
|
func (s *scriptProvider) MaxContextTokens() int { return 8192 }
|
||||||
|
|
||||||
|
func newTaskTestAgent(t *testing.T, sp agentAPI.Provider, sh *StageHost) *Agent {
|
||||||
|
t.Helper()
|
||||||
|
return New(AgentConfig{
|
||||||
|
ID: "task-test",
|
||||||
|
Provider: sp,
|
||||||
|
ProviderManager: agentAPI.NewProviderManager(),
|
||||||
|
StageHost: sh,
|
||||||
|
IO: agentIO.NewIOManager(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func tc(id, name string) agentAPI.ToolCall {
|
||||||
|
return agentAPI.ToolCall{ID: id, Name: name, Arguments: map[string]interface{}{"q": id}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// R3(M1 形态):一次工具轮 + 一次收尾轮,结果与工具调用计数必须正确。
|
||||||
|
func TestTaskFrame_R3_ToolRoundTrip(t *testing.T) {
|
||||||
|
sh := NewStageHost()
|
||||||
|
var got []string
|
||||||
|
sh.RegisterTool("t_echo", sdk.ToolDef{Name: "t_echo", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
got = append(got, args["q"].(string))
|
||||||
|
return "OUT", nil
|
||||||
|
})
|
||||||
|
|
||||||
|
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
|
||||||
|
{Content: "让我调用工具", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_echo")}},
|
||||||
|
{Content: "最终答复"},
|
||||||
|
}}
|
||||||
|
a := newTaskTestAgent(t, sp, sh)
|
||||||
|
|
||||||
|
stageCtx := a.stageCtxFromInput("你好", "", "")
|
||||||
|
resp, toolsUsed, toolResults, err := a.process("你好", stageCtx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("process 返回错误: %v", err)
|
||||||
|
}
|
||||||
|
if resp != "最终答复" {
|
||||||
|
t.Fatalf("响应=%q,期望 %q", resp, "最终答复")
|
||||||
|
}
|
||||||
|
if len(toolsUsed) != 1 || toolsUsed[0] != "t_echo" {
|
||||||
|
t.Fatalf("toolsUsed=%v,期望恰好一次 t_echo", toolsUsed)
|
||||||
|
}
|
||||||
|
if len(toolResults) != 1 || toolResults[0].Name != "t_echo" || toolResults[0].Output != "OUT" {
|
||||||
|
t.Fatalf("toolResults=%+v,期望一条 t_echo/OUT", toolResults)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0] != "c1" {
|
||||||
|
t.Fatalf("工具实参=%v,期望恰好执行一次且参数来自脚本", got)
|
||||||
|
}
|
||||||
|
if len(sp.reqs) != 2 {
|
||||||
|
t.Fatalf("LLM 调用次数=%d,期望 2(工具轮 + 收尾轮)", len(sp.reqs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二轮请求必须携带 assistant(tool_call) + tool 结果两条消息。
|
||||||
|
msgs := sp.reqs[1].Messages
|
||||||
|
var hasAssistantCall, hasToolResult bool
|
||||||
|
for _, m := range msgs {
|
||||||
|
if m.Role == "assistant" && len(m.ToolCalls) == 1 && m.ToolCalls[0].ID == "c1" {
|
||||||
|
hasAssistantCall = true
|
||||||
|
}
|
||||||
|
if m.Role == "tool" && m.ToolCallID == "c1" && m.Content == "OUT" {
|
||||||
|
hasToolResult = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasAssistantCall || !hasToolResult {
|
||||||
|
t.Fatalf("第二轮请求缺少工具调用配对:assistant=%v tool=%v", hasAssistantCall, hasToolResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// X3(M1 形态):多轮脚本必须在有限步内以一次终态返回结束。
|
||||||
|
func TestTaskFrame_X3_TerminatesWithinBudget(t *testing.T) {
|
||||||
|
sh := NewStageHost()
|
||||||
|
sh.RegisterTool("t_noop", sdk.ToolDef{Name: "t_noop", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
return "ok", nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3 个工具轮 + 收尾轮:状态机会在 StepLLM/StepToolBegin/.../StepTurnEnd 间往返 4 次。
|
||||||
|
var script []*agentAPI.CompletionResponse
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
script = append(script, &agentAPI.CompletionResponse{
|
||||||
|
Content: "round",
|
||||||
|
ToolCalls: []agentAPI.ToolCall{tc("c"+string(rune('a'+i)), "t_noop")},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
script = append(script, &agentAPI.CompletionResponse{Content: "done"})
|
||||||
|
|
||||||
|
sp := &scriptProvider{script: script}
|
||||||
|
a := newTaskTestAgent(t, sp, sh)
|
||||||
|
|
||||||
|
resp, toolsUsed, toolResults, err := a.process("跑三轮", a.stageCtxFromInput("跑三轮", "", ""))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("process 返回错误: %v", err)
|
||||||
|
}
|
||||||
|
if resp != "done" {
|
||||||
|
t.Fatalf("响应=%q,期望 done", resp)
|
||||||
|
}
|
||||||
|
if len(toolsUsed) != 3 || len(toolResults) != 3 {
|
||||||
|
t.Fatalf("toolsUsed=%d toolResults=%d,期望各 3", len(toolsUsed), len(toolResults))
|
||||||
|
}
|
||||||
|
// 步数护栏未触发(触发了会是 "step budget exhausted" 错误)。
|
||||||
|
if len(sp.reqs) != 4 {
|
||||||
|
t.Fatalf("LLM 调用次数=%d,期望 4", len(sp.reqs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态机对未知 step 必须失败退出而不是空转。
|
||||||
|
func TestTaskFrame_UnknownStepFails(t *testing.T) {
|
||||||
|
sp := &scriptProvider{}
|
||||||
|
a := newTaskTestAgent(t, sp, NewStageHost())
|
||||||
|
f := a.newTaskFrame("x", a.stageCtxFromInput("x", "", ""))
|
||||||
|
f.Step = Step(999)
|
||||||
|
if out := a.step(f); out != outcomeFailed {
|
||||||
|
t.Fatalf("未知 step 应返回 outcomeFailed,实际 %v", out)
|
||||||
|
}
|
||||||
|
if f.Err == nil {
|
||||||
|
t.Fatal("未知 step 必须带错误信息")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// D6:工具轮次硬上限——模型不停调用工具时,必须在有限步内收尾。
|
||||||
|
//
|
||||||
|
// 这是审查里定位的 P0(core.agent.max_tool_turns 只定义、从没被读过),
|
||||||
|
// 也是调度器的前提:任务必须可终止。
|
||||||
|
func TestMaxToolTurns_CapsRunawayLoop(t *testing.T) {
|
||||||
|
sh := NewStageHost()
|
||||||
|
sh.RegisterTool("t_loop", sdk.ToolDef{Name: "t_loop", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
return "again", nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// 脚本远长于上限:provider 每轮都给下一批工具调用,模拟“永不停止”。
|
||||||
|
script := make([]*agentAPI.CompletionResponse, 0, 20)
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
script = append(script, &agentAPI.CompletionResponse{
|
||||||
|
Content: "继续",
|
||||||
|
ToolCalls: []agentAPI.ToolCall{tc("c1", "t_loop")},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sp := &scriptProvider{script: script}
|
||||||
|
a := New(AgentConfig{
|
||||||
|
ID: "cap",
|
||||||
|
Provider: sp,
|
||||||
|
ProviderManager: agentAPI.NewProviderManager(),
|
||||||
|
IO: agentIO.NewIOManager(),
|
||||||
|
StageHost: sh,
|
||||||
|
MaxToolTurns: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, toolsUsed, toolResults, err := a.process("循环", a.stageCtxFromInput("循环", "", ""))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("process 返回错误: %v", err)
|
||||||
|
}
|
||||||
|
if len(toolsUsed) != 3 || len(toolResults) != 3 {
|
||||||
|
t.Fatalf("工具批=%d/%d,期望恰好 3(到上限即止,不多跑第 4 轮)", len(toolsUsed), len(toolResults))
|
||||||
|
}
|
||||||
|
if len(sp.reqs) != 3 {
|
||||||
|
t.Fatalf("LLM 调用=%d,期望 3(上限后不再发起新请求)", len(sp.reqs))
|
||||||
|
}
|
||||||
|
if resp != "继续" {
|
||||||
|
t.Fatalf("响应=%q,期望返回最近一次 LLM 文本", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上限为 0 表示不限(显式退出机制)。
|
||||||
|
func TestMaxToolTurns_ZeroMeansUnlimited(t *testing.T) {
|
||||||
|
sh := NewStageHost()
|
||||||
|
sh.RegisterTool("t_loop", sdk.ToolDef{Name: "t_loop", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
return "again", nil
|
||||||
|
})
|
||||||
|
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
|
||||||
|
{Content: "a", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_loop")}},
|
||||||
|
{Content: "b", ToolCalls: []agentAPI.ToolCall{tc("c2", "t_loop")}},
|
||||||
|
{Content: "c"},
|
||||||
|
}}
|
||||||
|
a := New(AgentConfig{
|
||||||
|
ID: "nocap",
|
||||||
|
Provider: sp,
|
||||||
|
ProviderManager: agentAPI.NewProviderManager(),
|
||||||
|
IO: agentIO.NewIOManager(),
|
||||||
|
StageHost: sh,
|
||||||
|
MaxToolTurns: 0,
|
||||||
|
})
|
||||||
|
resp, toolsUsed, _, err := a.process("x", a.stageCtxFromInput("x", "", ""))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("process 返回错误: %v", err)
|
||||||
|
}
|
||||||
|
if len(toolsUsed) != 2 || resp != "c" {
|
||||||
|
t.Fatalf("不限时应跑完脚本:tools=%d resp=%q", len(toolsUsed), resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,7 +13,7 @@ import (
|
|||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *Agent) executeToolCall(tc agentAPI.ToolCall) (ret string) {
|
func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string) (ret string) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
stack := debug.Stack()
|
stack := debug.Stack()
|
||||||
@ -31,7 +31,7 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) (ret string) {
|
|||||||
|
|
||||||
done := make(chan string, 1)
|
done := make(chan string, 1)
|
||||||
go func() {
|
go func() {
|
||||||
done <- a.executeToolCallInner(tc)
|
done <- a.executeToolCallInner(tc, channel)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
@ -43,7 +43,7 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) (ret string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall) string {
|
func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string) string {
|
||||||
switch {
|
switch {
|
||||||
case tc.Name == "persona_set":
|
case tc.Name == "persona_set":
|
||||||
return a.executePersonaTool(tc)
|
return a.executePersonaTool(tc)
|
||||||
@ -61,13 +61,21 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall) string {
|
|||||||
return a.executeOutputSendTool(tc)
|
return a.executeOutputSendTool(tc)
|
||||||
case tc.Name == "output_list_channels":
|
case tc.Name == "output_list_channels":
|
||||||
return a.executeOutputListChannels()
|
return a.executeOutputListChannels()
|
||||||
|
case tc.Name == "input_channels":
|
||||||
|
return a.executeInputChannels(tc)
|
||||||
|
case tc.Name == "resident_agents":
|
||||||
|
return a.executeResidentAgents(tc)
|
||||||
|
case tc.Name == "notify_parent":
|
||||||
|
return a.executeNotifyParent(tc)
|
||||||
|
case tc.Name == "inputch_note":
|
||||||
|
return a.executeInputchNote(tc)
|
||||||
case tc.Name == "plgreload":
|
case tc.Name == "plgreload":
|
||||||
return a.executePluginReload()
|
return a.executePluginReload()
|
||||||
case tc.Name == "get_plugin_tools":
|
case tc.Name == "get_plugin_tools":
|
||||||
pluginName, _ := tc.Arguments["plugin_name"].(string)
|
pluginName, _ := tc.Arguments["plugin_name"].(string)
|
||||||
return a.executeGetPluginTools(pluginName)
|
return a.executeGetPluginTools(pluginName)
|
||||||
case tc.Name == "spawn_child":
|
case tc.Name == "spawn_child":
|
||||||
return a.executeSpawnChild(tc)
|
return a.executeSpawnChild(tc, channel)
|
||||||
case tc.Name == "child_result":
|
case tc.Name == "child_result":
|
||||||
return a.executeChildResultTool(tc)
|
return a.executeChildResultTool(tc)
|
||||||
case strings.HasPrefix(tc.Name, "llm_"):
|
case strings.HasPrefix(tc.Name, "llm_"):
|
||||||
@ -88,6 +96,18 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设备类工具的**授权闸**(最小授权的缺口在这里)。
|
||||||
|
//
|
||||||
|
// 设备指令类工具(device_ctl_cmdrun/screensee/computeruse/...)走的是工具面,
|
||||||
|
// 而 AllowedOutputs 只作用于 output_send__<通道> —— 于是"授权"对指令类工具完全无效:
|
||||||
|
// 驻留子只要拿到 device_ctl_cmdrun 就能指挥**任意**设备。
|
||||||
|
// 这里按目标设备的通道名 device/<id> 查同一道闸:父授权了哪台设备,才允许指挥哪台。
|
||||||
|
if _, isDeviceTool := a.io.DeviceOfTool(tc.Name); isDeviceTool {
|
||||||
|
if id, _ := tc.Arguments["device_id"].(string); id != "" && !a.IsOutputAllowed("device/"+id) {
|
||||||
|
return fmt.Sprintf("设备 [%s] 未授权给本 agent(可用设备见 output_list_channels 的 device/<id> 通道,或 devicedetect)", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if a.tracker != nil {
|
if a.tracker != nil {
|
||||||
a.tracker.PreAction(tc.Name)
|
a.tracker.PreAction(tc.Name)
|
||||||
}
|
}
|
||||||
@ -104,12 +124,21 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||||
if a.memory == nil {
|
g := a.graphMem()
|
||||||
|
if g == nil {
|
||||||
if tc.Name == "memory_document_query" {
|
if tc.Name == "memory_document_query" {
|
||||||
return a.executeDocTool(tc)
|
return a.executeDocTool(tc)
|
||||||
}
|
}
|
||||||
return "图记忆系统不可用"
|
return "图记忆系统不可用"
|
||||||
}
|
}
|
||||||
|
// 整理类工具需要**完整内核**的记忆整理面(块/媒体/结构操作)。
|
||||||
|
// 轻量内核(驻留子)只有图记忆共同面 ⇒ 这些操作明确不可用,不静默降级。
|
||||||
|
requireFull := func() string {
|
||||||
|
if a.memory == nil {
|
||||||
|
return "本 agent 是轻量内核:只能读写图记忆,记忆整理(合并/删除/清理/编辑/统计)不可用"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
switch tc.Name {
|
switch tc.Name {
|
||||||
case "memory_recall":
|
case "memory_recall":
|
||||||
query, _ := tc.Arguments["query_intent"].(string)
|
query, _ := tc.Arguments["query_intent"].(string)
|
||||||
@ -125,7 +154,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
|||||||
if len(keywords) == 1 {
|
if len(keywords) == 1 {
|
||||||
keywords = memory.ExtractKeywords(query)
|
keywords = memory.ExtractKeywords(query)
|
||||||
}
|
}
|
||||||
result, err := a.memory.Recall(keywords, nil, int(depth), "")
|
result, err := g.Recall(keywords, nil, int(depth), "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Sprintf("记忆检索失败: %v", err)
|
return fmt.Sprintf("记忆检索失败: %v", err)
|
||||||
}
|
}
|
||||||
@ -163,6 +192,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
|||||||
return strings.Join(parts, "\n")
|
return strings.Join(parts, "\n")
|
||||||
|
|
||||||
case "memory_block_merge":
|
case "memory_block_merge":
|
||||||
|
if msg := requireFull(); msg != "" {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
entityA, _ := tc.Arguments["entity_a"].(string)
|
entityA, _ := tc.Arguments["entity_a"].(string)
|
||||||
entityB, _ := tc.Arguments["entity_b"].(string)
|
entityB, _ := tc.Arguments["entity_b"].(string)
|
||||||
rounds, _ := tc.Arguments["rounds"].(float64)
|
rounds, _ := tc.Arguments["rounds"].(float64)
|
||||||
@ -223,6 +255,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
|||||||
return fmt.Sprintf("已写入 %d 个实体和 %d 条关系", ec, rc)
|
return fmt.Sprintf("已写入 %d 个实体和 %d 条关系", ec, rc)
|
||||||
|
|
||||||
case "memory_introspect":
|
case "memory_introspect":
|
||||||
|
if msg := requireFull(); msg != "" {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
stats, err := a.memory.Introspect()
|
stats, err := a.memory.Introspect()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Sprintf("查询失败: %v", err)
|
return fmt.Sprintf("查询失败: %v", err)
|
||||||
@ -233,6 +268,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
|||||||
return a.executeDocTool(tc)
|
return a.executeDocTool(tc)
|
||||||
|
|
||||||
case "memory_merge":
|
case "memory_merge":
|
||||||
|
if msg := requireFull(); msg != "" {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
source, _ := tc.Arguments["source"].(string)
|
source, _ := tc.Arguments["source"].(string)
|
||||||
target, _ := tc.Arguments["target"].(string)
|
target, _ := tc.Arguments["target"].(string)
|
||||||
if source == "" || target == "" {
|
if source == "" || target == "" {
|
||||||
@ -245,6 +283,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
|||||||
return fmt.Sprintf("已将「%s」合并到「%s」,source 已彻底删除,%d 条关系已重定向", source, target, count)
|
return fmt.Sprintf("已将「%s」合并到「%s」,source 已彻底删除,%d 条关系已重定向", source, target, count)
|
||||||
|
|
||||||
case "memory_delete_entity":
|
case "memory_delete_entity":
|
||||||
|
if msg := requireFull(); msg != "" {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
name, _ := tc.Arguments["name"].(string)
|
name, _ := tc.Arguments["name"].(string)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return "name 不能为空"
|
return "name 不能为空"
|
||||||
@ -255,6 +296,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
|||||||
return fmt.Sprintf("已彻底删除实体「%s」及其所有关联关系", name)
|
return fmt.Sprintf("已彻底删除实体「%s」及其所有关联关系", name)
|
||||||
|
|
||||||
case "memory_purge":
|
case "memory_purge":
|
||||||
|
if msg := requireFull(); msg != "" {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
criteria := make(map[string]string)
|
criteria := make(map[string]string)
|
||||||
if v, ok := tc.Arguments["subject_contains"].(string); ok && v != "" {
|
if v, ok := tc.Arguments["subject_contains"].(string); ok && v != "" {
|
||||||
criteria["subject_contains"] = v
|
criteria["subject_contains"] = v
|
||||||
@ -289,6 +333,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
|||||||
return strings.Join(parts, ",")
|
return strings.Join(parts, ",")
|
||||||
|
|
||||||
case "memory_edit":
|
case "memory_edit":
|
||||||
|
if msg := requireFull(); msg != "" {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
oldSubject, _ := tc.Arguments["old_subject"].(string)
|
oldSubject, _ := tc.Arguments["old_subject"].(string)
|
||||||
oldRelation, _ := tc.Arguments["old_relation"].(string)
|
oldRelation, _ := tc.Arguments["old_relation"].(string)
|
||||||
oldObject, _ := tc.Arguments["old_object"].(string)
|
oldObject, _ := tc.Arguments["old_object"].(string)
|
||||||
|
|||||||
@ -43,6 +43,11 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
|||||||
prompt = "你是小宅,HomeAgent 的看板娘,一个家政型 AI 管家助手。绝不用 Unicode emoji,只用颜文字表达情感,句尾带语气词。WebUI 概览页展示你的立绘。"
|
prompt = "你是小宅,HomeAgent 的看板娘,一个家政型 AI 管家助手。绝不用 Unicode emoji,只用颜文字表达情感,句尾带语气词。WebUI 概览页展示你的立绘。"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 驻留子:在**固定提示词之上**注入任务提示词(设计 §7「创建」)。
|
||||||
|
if a.taskPrompt != "" {
|
||||||
|
prompt += "\n\n【任务】" + a.taskPrompt
|
||||||
|
}
|
||||||
|
|
||||||
if a.personality != nil {
|
if a.personality != nil {
|
||||||
if pp := a.personality.InjectPrompt(); pp != "" {
|
if pp := a.personality.InjectPrompt(); pp != "" {
|
||||||
prompt += "\n\n" + pp
|
prompt += "\n\n" + pp
|
||||||
@ -68,8 +73,8 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
|||||||
prompt += "\n\n【中断消息】长任务执行期间,工具/插件/定时器等会通过中断机制向你发送提醒(如 QQ 新消息、终端输出到达、定时器到点等)。中断消息以 system 角色注入,内容带 [中断消息] 前缀,**不是用户发言,但也必须认真处理**:优先停下当前长任务,针对中断内容作出响应或决定继续执行。不要忽略带 [中断消息] 前缀的 system 消息。"
|
prompt += "\n\n【中断消息】长任务执行期间,工具/插件/定时器等会通过中断机制向你发送提醒(如 QQ 新消息、终端输出到达、定时器到点等)。中断消息以 system 角色注入,内容带 [中断消息] 前缀,**不是用户发言,但也必须认真处理**:优先停下当前长任务,针对中断内容作出响应或决定继续执行。不要忽略带 [中断消息] 前缀的 system 消息。"
|
||||||
|
|
||||||
prompt += "\n\n【输出规则】消息不会自动发送到对话来源通道,你必须自己决定如何回复:\n"
|
prompt += "\n\n【输出规则】消息不会自动发送到对话来源通道,你必须自己决定如何回复:\n"
|
||||||
prompt += "- 当前输入来自哪个通道,就优先用哪个通道回复;不要串到其他通道(除非用户明确要求)。\n"
|
prompt += "- **不要假设当前通道是某个固定值**:同一会话里可能同时有多个来源(多设备、多通道、子任务)。\n"
|
||||||
prompt += "- 当前输入来源通道(即对话发生的通道)是:" + a.currentOutputChannel + "。对应输出门工具是 output_send__{该通道名}。\n"
|
prompt += " 先看这条消息本身与上下文里的来源信息,再决定往哪里回;不确定有哪些通道时先调 output_list_channels。\n"
|
||||||
prompt += "- 同步通道(webui / cli / 终端):直接返回纯文本,内核会把文本交给等待方显示,无需调用工具。\n"
|
prompt += "- 同步通道(webui / cli / 终端):直接返回纯文本,内核会把文本交给等待方显示,无需调用工具。\n"
|
||||||
prompt += "- 异步通道(qq / wechat / 群聊等):返回纯文本**【不会】**自动送达用户,必须调用 output_send__{通道名} 工具(注意 meta 里带上正确的 user_id 或 group_id)才能真正把消息发出去。\n"
|
prompt += "- 异步通道(qq / wechat / 群聊等):返回纯文本**【不会】**自动送达用户,必须调用 output_send__{通道名} 工具(注意 meta 里带上正确的 user_id 或 group_id)才能真正把消息发出去。\n"
|
||||||
prompt += "- 不确定当前通道的发送方式时,先用 output_send__{通道名}_help 查看该通道的 meta 格式和 type 枚举,再决定。\n"
|
prompt += "- 不确定当前通道的发送方式时,先用 output_send__{通道名}_help 查看该通道的 meta 格式和 type 枚举,再决定。\n"
|
||||||
@ -580,6 +585,11 @@ func (a *Agent) buildToolDefs() []interface{} {
|
|||||||
if ch.Type != agentIO.DeviceOutput && ch.Type != agentIO.DeviceIO {
|
if ch.Type != agentIO.DeviceOutput && ch.Type != agentIO.DeviceIO {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// 输出通道授权(设计 §4.4/R2):默认完整授权;父可用白名单收窄子的输出能力。
|
||||||
|
// 未授权就不生成 output_send__X —— 模型看不到它,自然不会调。
|
||||||
|
if !a.IsOutputAllowed(ch.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
capStr := a.io.GetChannelCapabilities(ch.Name).String()
|
capStr := a.io.GetChannelCapabilities(ch.Name).String()
|
||||||
desc := ch.Description
|
desc := ch.Description
|
||||||
if desc == "" {
|
if desc == "" {
|
||||||
@ -637,6 +647,90 @@ func (a *Agent) buildToolDefs() []interface{} {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 父侧:驻留子控制面(单工具多动作,见设计 §7)。
|
||||||
|
if a.parentID == "" {
|
||||||
|
tools = append(tools, map[string]interface{}{
|
||||||
|
"type": "function",
|
||||||
|
"function": map[string]interface{}{
|
||||||
|
"name": "resident_agents",
|
||||||
|
"description": "管理驻留子 agent(长期派驻的下属):list 列出 / create 创建(划入 inputch + " +
|
||||||
|
"授权输出通道 + 注入任务提示词)/ send 发送消息(对子而言是 L4 中断,取消其当前状态并插入新消息)" +
|
||||||
|
"/ inspect 查看其 inputch 处理表(不打断它)/ compress 压缩其上下文(保留语义,子继续存在)" +
|
||||||
|
"/ reclaim 回收(父选哪些纳入主记忆,然后取消该子)/ destroy 立刻销毁并移除。",
|
||||||
|
"parameters": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"action": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"list", "create", "send", "inspect", "compress", "reclaim", "destroy"},
|
||||||
|
},
|
||||||
|
"id": map[string]interface{}{"type": "string", "description": "驻留子 id"},
|
||||||
|
"task_prompt": map[string]interface{}{"type": "string", "description": "create:在固定提示词之上注入的任务提示词"},
|
||||||
|
"input_chs": map[string]interface{}{"type": "string", "description": "create:划入的 inputch(逗号分隔)"},
|
||||||
|
"allowed_outputs": map[string]interface{}{"type": "string", "description": "create:授权的输出通道(逗号分隔;留空=完整授权)"},
|
||||||
|
"capacity": map[string]interface{}{"type": "number", "description": "create:划入 inputch 的队列容量"},
|
||||||
|
"temp_path": map[string]interface{}{"type": "string", "description": "create:temp 图记忆路径(留空则用 data_dir/residents/<id>/graph.db)"},
|
||||||
|
"text": map[string]interface{}{"type": "string", "description": "send:要发给子 agent 的消息"},
|
||||||
|
},
|
||||||
|
"required": []string{"action"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 子侧(驻留子):主动汇报(L3)与主动写处理表。
|
||||||
|
if a.parentID != "" {
|
||||||
|
tools = append(tools, map[string]interface{}{
|
||||||
|
"type": "function",
|
||||||
|
"function": map[string]interface{}{
|
||||||
|
"name": "notify_parent",
|
||||||
|
"description": "向主 agent 汇报(以 L3 中断投给它)。用于主动报告进展/结论,而不是等它来问。",
|
||||||
|
"parameters": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{"text": map[string]interface{}{"type": "string", "description": "汇报内容"}},
|
||||||
|
"required": []string{"text"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
tools = append(tools, map[string]interface{}{
|
||||||
|
"type": "function",
|
||||||
|
"function": map[string]interface{}{
|
||||||
|
"name": "inputch_note",
|
||||||
|
"description": "为**本轮** inputch 主动写入处理信息(主 agent 会查这张表判断你的进度)。" +
|
||||||
|
"写了就不会再被系统自动记录;不写则本轮结束时系统自动写。",
|
||||||
|
"parameters": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{"text": map[string]interface{}{"type": "string", "description": "本轮处理信息摘要"}},
|
||||||
|
"required": []string{"text"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
tools = append(tools, map[string]interface{}{
|
||||||
|
"type": "function",
|
||||||
|
"function": map[string]interface{}{
|
||||||
|
"name": "input_channels",
|
||||||
|
"description": "查看 inputch(最基本的输入路由单位):哪些已注册、谁注册的、" +
|
||||||
|
"各自划给了哪个 agent、容量与记忆策略。单工具多视图。",
|
||||||
|
"parameters": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"view": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "all=全部已注册(默认)| mine=划给本 agent 的 | " +
|
||||||
|
"unassigned=尚未划出的 | by_agent=按归属分组的划分总览 | detail=单个详情",
|
||||||
|
"enum": []string{"all", "mine", "unassigned", "by_agent", "detail"},
|
||||||
|
},
|
||||||
|
"name": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "view=detail 时必填:inputch 名",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
if a.pendingMedia != nil {
|
if a.pendingMedia != nil {
|
||||||
tools = append(tools, map[string]interface{}{
|
tools = append(tools, map[string]interface{}{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
|
|||||||
@ -96,13 +96,13 @@ type OutputEvent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type IOManager struct {
|
type IOManager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
devices map[string]Device
|
devices map[string]Device
|
||||||
inputCh chan *InputEvent
|
inputCh chan *InputEvent
|
||||||
interruptCh chan *InputEvent
|
interruptCh chan *InputEvent
|
||||||
outputCh chan *OutputEvent
|
outputCh chan *OutputEvent
|
||||||
nextReqID int64
|
nextReqID int64
|
||||||
inputChannels map[string]ChannelDef
|
channelReg *ChannelRegistry
|
||||||
|
|
||||||
// toolBlocks:插件工具注入多模态内容块,process.go 在下一条 tool message 时消费。
|
// toolBlocks:插件工具注入多模态内容块,process.go 在下一条 tool message 时消费。
|
||||||
// 用 interface{}[] 避免 import api.ContentBlock 导致的循环依赖。
|
// 用 interface{}[] 避免 import api.ContentBlock 导致的循环依赖。
|
||||||
@ -112,11 +112,11 @@ type IOManager struct {
|
|||||||
|
|
||||||
func NewIOManager() *IOManager {
|
func NewIOManager() *IOManager {
|
||||||
return &IOManager{
|
return &IOManager{
|
||||||
devices: make(map[string]Device),
|
devices: make(map[string]Device),
|
||||||
inputCh: make(chan *InputEvent, 256),
|
inputCh: make(chan *InputEvent, 256),
|
||||||
interruptCh: make(chan *InputEvent, 64),
|
interruptCh: make(chan *InputEvent, 64),
|
||||||
outputCh: make(chan *OutputEvent, 256),
|
outputCh: make(chan *OutputEvent, 256),
|
||||||
inputChannels: make(map[string]ChannelDef),
|
channelReg: NewChannelRegistry(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -266,6 +266,10 @@ func applyInjectOpts(payload map[string]interface{}, opts InjectOptions) {
|
|||||||
if opts.CleanerName != "" {
|
if opts.CleanerName != "" {
|
||||||
payload["cleaner_name"] = opts.CleanerName
|
payload["cleaner_name"] = opts.CleanerName
|
||||||
}
|
}
|
||||||
|
// priority 只对中断注入有意义;排队路径会忽略它(内核侧只读不写)。
|
||||||
|
if opts.Priority != "" {
|
||||||
|
payload["priority"] = opts.Priority
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *IOManager) InjectInputOpts(source, eventType string, payload map[string]interface{}, opts InjectOptions) {
|
func (m *IOManager) InjectInputOpts(source, eventType string, payload map[string]interface{}, opts InjectOptions) {
|
||||||
@ -440,26 +444,57 @@ func (m *IOManager) EmitTextTo(target, outputChannel, text string) {
|
|||||||
func (m *IOManager) InputChan() <-chan *InputEvent { return m.inputCh }
|
func (m *IOManager) InputChan() <-chan *InputEvent { return m.inputCh }
|
||||||
func (m *IOManager) OutputChan() <-chan *OutputEvent { return m.outputCh }
|
func (m *IOManager) OutputChan() <-chan *OutputEvent { return m.outputCh }
|
||||||
|
|
||||||
// RegisterInputChannel 注册输入通道的记忆行为
|
// RegisterInputChannel 注册一个 inputch(不带插件归属,兼容旧调用)。
|
||||||
|
//
|
||||||
|
// inputch 是**最基本的输入路由单位**;一个插件可以注册多个。
|
||||||
|
// 新代码请用 RegisterInputChannelFrom 以便登记归属插件(可追溯)。
|
||||||
func (m *IOManager) RegisterInputChannel(name string, def ChannelDef) {
|
func (m *IOManager) RegisterInputChannel(name string, def ChannelDef) {
|
||||||
m.mu.Lock()
|
_ = m.RegisterInputChannelFrom("", name, def)
|
||||||
defer m.mu.Unlock()
|
|
||||||
m.inputChannels[name] = def
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnregisterInputChannel 注销输入通道
|
// RegisterInputChannelFrom 注册一个 inputch 并登记归属插件。
|
||||||
|
func (m *IOManager) RegisterInputChannelFrom(plugin, name string, def ChannelDef) error {
|
||||||
|
return m.channelReg.Register(InputChannel{Name: name, Plugin: plugin, Def: def})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnregisterInputChannel 注销一个 inputch。
|
||||||
func (m *IOManager) UnregisterInputChannel(name string) {
|
func (m *IOManager) UnregisterInputChannel(name string) {
|
||||||
m.mu.Lock()
|
m.channelReg.Unregister(name)
|
||||||
defer m.mu.Unlock()
|
|
||||||
delete(m.inputChannels, name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetInputChannelDef 查询输入通道的记忆行为定义
|
// AssignInputChannel 把一个 inputch 划给某个 agent(见 ChannelRegistry.Assign)。
|
||||||
|
func (m *IOManager) AssignInputChannel(name, agentID string, capacity int) error {
|
||||||
|
return m.channelReg.Assign(name, agentID, capacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetChannelRegistry 注入一份**共享的**登记表(根 agent 与驻留子共用同一份)。
|
||||||
|
func (m *IOManager) SetChannelRegistry(r *ChannelRegistry) {
|
||||||
|
if r == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.channelReg = r
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelRegistry 返回底层登记表(只读用途;可直接读 Views)。
|
||||||
|
func (m *IOManager) ChannelRegistry() *ChannelRegistry { return m.channelReg }
|
||||||
|
|
||||||
|
// InputChannels 返回全部已注册 inputch(按名字排序)。
|
||||||
|
func (m *IOManager) InputChannels() []InputChannel { return m.channelReg.List() }
|
||||||
|
|
||||||
|
// LookupInputChannel 查询单个 inputch 的完整登记记录。
|
||||||
|
func (m *IOManager) LookupInputChannel(name string) (InputChannel, bool) {
|
||||||
|
return m.channelReg.Lookup(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInputChannelDef 查询 inputch 的记忆行为定义。
|
||||||
func (m *IOManager) GetInputChannelDef(name string) (ChannelDef, bool) {
|
func (m *IOManager) GetInputChannelDef(name string) (ChannelDef, bool) {
|
||||||
m.mu.RLock()
|
ch, ok := m.channelReg.Lookup(name)
|
||||||
defer m.mu.RUnlock()
|
if !ok {
|
||||||
def, ok := m.inputChannels[name]
|
return ChannelDef{}, false
|
||||||
return def, ok
|
}
|
||||||
|
return ch.Def, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *IOManager) GetAllTools() []ToolDef {
|
func (m *IOManager) GetAllTools() []ToolDef {
|
||||||
@ -472,6 +507,23 @@ func (m *IOManager) GetAllTools() []ToolDef {
|
|||||||
return tools
|
return tools
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeviceOfTool 返回提供该工具的**设备/输出通道名**(设备类工具才有)。
|
||||||
|
//
|
||||||
|
// 用途:设备类工具(device_ctl_*/screensee/computeruse/...)需要按"目标设备"
|
||||||
|
// 做授权判断,调用方得先知道这个工具属于哪个设备通道。
|
||||||
|
func (m *IOManager) DeviceOfTool(name string) (string, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
for _, dev := range m.devices {
|
||||||
|
for _, t := range dev.Tools() {
|
||||||
|
if t.Name == name {
|
||||||
|
return dev.Name(), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
func (m *IOManager) ExecuteTool(name string, args map[string]interface{}) (ret interface{}, err error) {
|
func (m *IOManager) ExecuteTool(name string, args map[string]interface{}) (ret interface{}, err error) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
type nameDevice struct {
|
type nameDevice struct {
|
||||||
|
|||||||
203
internal/agent/io/inputch.go
Normal file
203
internal/agent/io/inputch.go
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
package io
|
||||||
|
|
||||||
|
// inputch 是**最基本的输入路由单位**(设计见 docs/zh/resident-subagent-design.md §4)。
|
||||||
|
//
|
||||||
|
// 一个插件可以注册多个 inputch;每个 inputch 是彼此独立的路由单位:
|
||||||
|
// 可以被划给不同的 agent、可以分别限额。中断输入与排队输入**两类都从 inputch 进出**,
|
||||||
|
// 而"中断 vs 排队"是每条输入自己的类别 —— 不是 inputch 的属性。
|
||||||
|
//
|
||||||
|
// 本文件只放**登记表**(谁是注册者、划给了谁、容量多少、记忆策略是什么)。
|
||||||
|
// 路由本身发生在进内核之前:投递方决定"这条输入投给哪个 inputch"。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrInputChannelUnknown 表示引用了未注册的 inputch。
|
||||||
|
ErrInputChannelUnknown = errors.New("inputch 未注册")
|
||||||
|
// ErrInputChannelNameEmpty 表示 inputch 名为空。
|
||||||
|
ErrInputChannelNameEmpty = errors.New("inputch 名不能为空")
|
||||||
|
)
|
||||||
|
|
||||||
|
// InputChannel 是一个 inputch 的完整登记记录。
|
||||||
|
type InputChannel struct {
|
||||||
|
// Name 是路由单位 id(全局唯一,如 "qq"、"webui"、"qq/device-2")。
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Plugin 是注册它的插件名("插件可注册多个 inputch",归属可追溯)。
|
||||||
|
Plugin string `json:"plugin,omitempty"`
|
||||||
|
// Owner 是**被划给的 agent id**("" = 未分配,归根 agent/内核默认)。
|
||||||
|
Owner string `json:"owner,omitempty"`
|
||||||
|
// Capacity 是该 inputch 的队列容量(0 = 用内核默认值)。
|
||||||
|
Capacity int `json:"capacity,omitempty"`
|
||||||
|
// Output 是该 inputch 的默认回程输出通道("" = 由来源/调用方决定)。
|
||||||
|
//
|
||||||
|
// 注意:这不是"内核路由"—— 输出仍然是 agent 的主动调用;这里只是登记
|
||||||
|
// "这个 inputch 的回复默认该往哪个 outputch 走"的映射依据。
|
||||||
|
Output string `json:"output,omitempty"`
|
||||||
|
// Def 是记忆/上下文策略(沿用 ChannelDef:NoMemory / Cleaner / ContextPolicy)。
|
||||||
|
// Cleaner 是函数,故本字段不可序列化(json:"-")。
|
||||||
|
Def ChannelDef `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelRegistry 是 inputch 的登记表。
|
||||||
|
//
|
||||||
|
// 它被设计成**可共享对象**(`*ChannelRegistry`):根 agent 与它的驻留子共用同一份,
|
||||||
|
// 这样"划入/授权"才有意义;每个 IOManager 默认自带一份(向后兼容)。
|
||||||
|
type ChannelRegistry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
channels map[string]InputChannel
|
||||||
|
// outputTargets 把**输出通道**解析成"目标 agent 的哪个 inputch"。
|
||||||
|
// 这是"输出可寻址到具体 agent"的依据(子→父、父→指定子)。
|
||||||
|
outputTargets map[string]OutputTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutputTarget 是一个输出通道的投递目标。
|
||||||
|
type OutputTarget struct {
|
||||||
|
// AgentID 是目标 agent("" = 本 agent / 由传输层通道 device 自行处理)。
|
||||||
|
AgentID string `json:"agent_id,omitempty"`
|
||||||
|
// InputCh 是目标 agent 上接收它的 inputch("" = 与输出通道同名)。
|
||||||
|
InputCh string `json:"inputch,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChannelRegistry 构造一个空的 inputch 登记表。
|
||||||
|
func NewChannelRegistry() *ChannelRegistry {
|
||||||
|
return &ChannelRegistry{
|
||||||
|
channels: make(map[string]InputChannel),
|
||||||
|
outputTargets: make(map[string]OutputTarget),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register 登记/更新一个 inputch。
|
||||||
|
//
|
||||||
|
// 重复登记(插件重载)时**保留已有的 Owner/Capacity/Output**,只更新
|
||||||
|
// Plugin 与 Def —— 否则一次插件重载就会把父 agent 做的划分抹掉。
|
||||||
|
func (r *ChannelRegistry) Register(ch InputChannel) error {
|
||||||
|
if ch.Name == "" {
|
||||||
|
return ErrInputChannelNameEmpty
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.channels == nil {
|
||||||
|
r.channels = make(map[string]InputChannel)
|
||||||
|
}
|
||||||
|
if old, ok := r.channels[ch.Name]; ok {
|
||||||
|
if ch.Owner == "" {
|
||||||
|
ch.Owner = old.Owner
|
||||||
|
}
|
||||||
|
if ch.Capacity == 0 {
|
||||||
|
ch.Capacity = old.Capacity
|
||||||
|
}
|
||||||
|
if ch.Output == "" {
|
||||||
|
ch.Output = old.Output
|
||||||
|
}
|
||||||
|
if ch.Plugin == "" {
|
||||||
|
ch.Plugin = old.Plugin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.channels[ch.Name] = ch
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister 注销一个 inputch。
|
||||||
|
func (r *ChannelRegistry) Unregister(name string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
delete(r.channels, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup 查询一个 inputch。
|
||||||
|
func (r *ChannelRegistry) Lookup(name string) (InputChannel, bool) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
ch, ok := r.channels[name]
|
||||||
|
return ch, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign 把一个 inputch **划给**某个 agent(可同时给定容量)。
|
||||||
|
//
|
||||||
|
// 语义(默认取值,见设计文档 R6):**读写授权**,不转移所有权 ——
|
||||||
|
// 登记表仍记录 Plugin(谁注册的)与 Owner(划给了谁)两件事。
|
||||||
|
func (r *ChannelRegistry) Assign(name, agentID string, capacity int) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
ch, ok := r.channels[name]
|
||||||
|
if !ok {
|
||||||
|
return ErrInputChannelUnknown
|
||||||
|
}
|
||||||
|
ch.Owner = agentID
|
||||||
|
if capacity > 0 {
|
||||||
|
ch.Capacity = capacity
|
||||||
|
}
|
||||||
|
r.channels[name] = ch
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 返回全部已注册 inputch(按名字稳定排序)。
|
||||||
|
func (r *ChannelRegistry) List() []InputChannel {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
out := make([]InputChannel, 0, len(r.channels))
|
||||||
|
for _, ch := range r.channels {
|
||||||
|
out = append(out, ch)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByOwner 返回划给某个 agent 的 inputch(owner == "" 时返回**未分配**的)。
|
||||||
|
func (r *ChannelRegistry) ListByOwner(agentID string) []InputChannel {
|
||||||
|
var out []InputChannel
|
||||||
|
for _, ch := range r.List() {
|
||||||
|
if ch.Owner == agentID {
|
||||||
|
out = append(out, ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindOutputTarget 登记"输出通道 → 目标 agent 的 inputch"的解析。
|
||||||
|
//
|
||||||
|
// 例:父把子用的输出通道 "to-child-1" 绑到 (child-1, "sub/in"),
|
||||||
|
// 于是子经该通道发出的消息会投进 child-1 的 sub/in。
|
||||||
|
func (r *ChannelRegistry) BindOutputTarget(output, agentID, inputCh string) error {
|
||||||
|
if output == "" {
|
||||||
|
return errors.New("输出通道名不能为空")
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.outputTargets == nil {
|
||||||
|
r.outputTargets = make(map[string]OutputTarget)
|
||||||
|
}
|
||||||
|
r.outputTargets[output] = OutputTarget{AgentID: agentID, InputCh: inputCh}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveOutputTarget 解析一个输出通道的目标;未登记时 ok=false
|
||||||
|
// (意味着由传输层通道自行处理,如 qq/webui 这类 device 通道)。
|
||||||
|
func (r *ChannelRegistry) ResolveOutputTarget(output string) (OutputTarget, bool) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
t, ok := r.outputTargets[output]
|
||||||
|
return t, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOutputTargets 返回全部已登记的目标解析(按输出通道名排序)。
|
||||||
|
func (r *ChannelRegistry) ListOutputTargets() map[string]OutputTarget {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
out := make(map[string]OutputTarget, len(r.outputTargets))
|
||||||
|
for k, v := range r.outputTargets {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count 返回已注册 inputch 数量。
|
||||||
|
func (r *ChannelRegistry) Count() int {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
return len(r.channels)
|
||||||
|
}
|
||||||
134
internal/agent/io/inputch_test.go
Normal file
134
internal/agent/io/inputch_test.go
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
package io
|
||||||
|
|
||||||
|
// inputch 登记表与划分(单工具多视图背后的数据面)。
|
||||||
|
//
|
||||||
|
// 设计依据 docs/zh/resident-subagent-design.md §4:
|
||||||
|
// inputch 是**最基本的输入路由单位**,由插件注册,一个插件可注册多个。
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestChannelRegistry_RegisterKeepsAttribution(t *testing.T) {
|
||||||
|
r := NewChannelRegistry()
|
||||||
|
|
||||||
|
// 一个插件注册多个 inputch(最基本的输入路由单位)。
|
||||||
|
if err := r.Register(InputChannel{Name: "qq", Plugin: "qq", Def: ChannelDef{NoMemory: true}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := r.Register(InputChannel{Name: "qq/device-2", Plugin: "qq"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := r.Count(); n != 2 {
|
||||||
|
t.Fatalf("注册数=%d,期望 2(同一插件的多个 inputch 彼此独立)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
ch, ok := r.Lookup("qq/device-2")
|
||||||
|
if !ok || ch.Plugin != "qq" {
|
||||||
|
t.Fatalf("归属插件未记录:%+v ok=%v", ch, ok)
|
||||||
|
}
|
||||||
|
if _, ok := r.Lookup("nope"); ok {
|
||||||
|
t.Fatal("未注册的 inputch 不应查得到")
|
||||||
|
}
|
||||||
|
if err := r.Register(InputChannel{Name: ""}); err != ErrInputChannelNameEmpty {
|
||||||
|
t.Fatalf("空名应报错,实际 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插件重载(重复登记)**不得抹掉划分**:Owner/Capacity/Output 必须保留。
|
||||||
|
func TestChannelRegistry_ReRegisterKeepsAllocation(t *testing.T) {
|
||||||
|
r := NewChannelRegistry()
|
||||||
|
_ = r.Register(InputChannel{Name: "qq", Plugin: "qq"})
|
||||||
|
if err := r.Assign("qq", "agent-1", 128); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插件重载:只带 Plugin/Def,不带 Owner/Capacity。
|
||||||
|
if err := r.Register(InputChannel{Name: "qq", Plugin: "qq", Def: ChannelDef{NoMemory: true}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ch, _ := r.Lookup("qq")
|
||||||
|
if ch.Owner != "agent-1" || ch.Capacity != 128 {
|
||||||
|
t.Fatalf("重载后划分被抹掉:owner=%q capacity=%d", ch.Owner, ch.Capacity)
|
||||||
|
}
|
||||||
|
if !ch.Def.NoMemory {
|
||||||
|
t.Fatal("重载应更新 Def")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelRegistry_AssignAndViews(t *testing.T) {
|
||||||
|
r := NewChannelRegistry()
|
||||||
|
_ = r.Register(InputChannel{Name: "qq", Plugin: "qq"})
|
||||||
|
_ = r.Register(InputChannel{Name: "cli", Plugin: "cli"})
|
||||||
|
_ = r.Register(InputChannel{Name: "sub/in", Plugin: "sub"})
|
||||||
|
|
||||||
|
if err := r.Assign("qq", "root", 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := r.Assign("sub/in", "child-1", 32); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := r.Assign("nope", "x", 0); err != ErrInputChannelUnknown {
|
||||||
|
t.Fatalf("划分未注册的 inputch 应报错,实际 %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := len(r.ListByOwner("root")); got != 1 {
|
||||||
|
t.Fatalf("root 的 inputch 数=%d,期望 1", got)
|
||||||
|
}
|
||||||
|
child := r.ListByOwner("child-1")
|
||||||
|
if len(child) != 1 || child[0].Name != "sub/in" || child[0].Capacity != 32 {
|
||||||
|
t.Fatalf("child-1 的划分=%+v", child)
|
||||||
|
}
|
||||||
|
// 未分配的:cli。
|
||||||
|
un := r.ListByOwner("")
|
||||||
|
if len(un) != 1 || un[0].Name != "cli" {
|
||||||
|
t.Fatalf("未分配的 inputch=%+v,期望只有 cli", un)
|
||||||
|
}
|
||||||
|
// List 按名排序(视图输出稳定)。
|
||||||
|
all := r.List()
|
||||||
|
if len(all) != 3 || all[0].Name != "cli" || all[2].Name != "sub/in" {
|
||||||
|
t.Fatalf("List 未按名排序:%+v", all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 共享登记表:根 agent 与驻留子共用同一份,划分才有意义。
|
||||||
|
func TestChannelRegistry_SharedBetweenManagers(t *testing.T) {
|
||||||
|
shared := NewChannelRegistry()
|
||||||
|
root := NewIOManager()
|
||||||
|
child := NewIOManager()
|
||||||
|
root.SetChannelRegistry(shared)
|
||||||
|
child.SetChannelRegistry(shared)
|
||||||
|
|
||||||
|
if err := root.RegisterInputChannelFrom("sub", "sub/in", ChannelDef{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := root.AssignInputChannel("sub/in", "child-1", 8); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 子在**自己**的 io 上就能看到这份划分。
|
||||||
|
ch, ok := child.LookupInputChannel("sub/in")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("共享登记表后,子应看得到根注册的 inputch")
|
||||||
|
}
|
||||||
|
if ch.Owner != "child-1" || ch.Capacity != 8 {
|
||||||
|
t.Fatalf("子看到的划分=%+v", ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注销也要跨 manager 生效。
|
||||||
|
root.UnregisterInputChannel("sub/in")
|
||||||
|
if _, ok := child.LookupInputChannel("sub/in"); ok {
|
||||||
|
t.Fatal("注销应跨 manager 生效")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记忆策略查询保持向后兼容(原 GetInputChannelDef 的语义)。
|
||||||
|
func TestChannelRegistry_DefLookupCompat(t *testing.T) {
|
||||||
|
m := NewIOManager()
|
||||||
|
m.RegisterInputChannel("qq", ChannelDef{NoMemory: true, ContextPolicy: "prune"})
|
||||||
|
def, ok := m.GetInputChannelDef("qq")
|
||||||
|
if !ok || !def.NoMemory || def.ContextPolicy != "prune" {
|
||||||
|
t.Fatalf("策略查询=%+v ok=%v", def, ok)
|
||||||
|
}
|
||||||
|
if _, ok := m.GetInputChannelDef("nope"); ok {
|
||||||
|
t.Fatal("未注册的 inputch 不应有策略")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,16 +10,27 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CmdHandler 是命令处理回调类型。
|
// CmdHandler 是本地命令路由回调类型。
|
||||||
// 当收到 remotedevice 下发的 cmd 时调用,reqID 用于回执,command 是命令内容。
|
|
||||||
type CmdHandler func(reqID, command string)
|
type CmdHandler func(reqID, command string)
|
||||||
|
|
||||||
|
// BridgeCmdHandler 接收服务端明确下发的路由信号(shell 或 homeagent)。
|
||||||
|
type BridgeCmdHandler func(reqID, command, cmdType string)
|
||||||
|
|
||||||
// CmdResult 是命令执行结果回调(用于异步通知 GUI 层)。
|
// CmdResult 是命令执行结果回调(用于异步通知 GUI 层)。
|
||||||
type CmdResultHandler func(reqID, status, output, errMsg string)
|
type CmdResultHandler func(reqID, status, output, errMsg string)
|
||||||
|
|
||||||
// DataHandler 是二进制数据接收回调(如 TTS 音频)。
|
// DataHandler 是二进制数据接收回调(如 TTS 音频)。
|
||||||
type DataHandler func(reqID, kind, mime string, data []byte)
|
type DataHandler func(reqID, kind, mime string, data []byte)
|
||||||
|
|
||||||
|
// PushHandler 接收 agent **主动投递**给本设备的内容。
|
||||||
|
//
|
||||||
|
// 与 cmd 的区别:cmd 是"让设备做一件事"(请求-响应,结果要回传),
|
||||||
|
// push 是"把这段内容交给设备"(agent 经 output_send__device/<id> 发起,
|
||||||
|
// 一种单向投递)。宿主按自己的形态落地:终端打出来、音箱念出来、屏幕显示。
|
||||||
|
//
|
||||||
|
// typ: text / structured / image / file / audio(二进制走 DataHandler,不走这里)
|
||||||
|
type PushHandler func(reqID, typ, payload, meta string)
|
||||||
|
|
||||||
// Bridge 是设备桥客户端核心结构体。
|
// Bridge 是设备桥客户端核心结构体。
|
||||||
// 管理 WebSocket 连接、消息路由、心跳保活和命令分发。
|
// 管理 WebSocket 连接、消息路由、心跳保活和命令分发。
|
||||||
// 授权状态由设备端本地存储(客户端鉴权),服务端不存储;
|
// 授权状态由设备端本地存储(客户端鉴权),服务端不存储;
|
||||||
@ -41,9 +52,10 @@ type Bridge struct {
|
|||||||
started bool
|
started bool
|
||||||
|
|
||||||
// 回调
|
// 回调
|
||||||
cmdHandler CmdHandler
|
cmdHandler BridgeCmdHandler
|
||||||
resultHandler CmdResultHandler
|
resultHandler CmdResultHandler
|
||||||
dataHandler DataHandler
|
dataHandler DataHandler
|
||||||
|
pushHandler PushHandler
|
||||||
|
|
||||||
// 二进制数据聚合(服务端→设备,如 TTS 音频)
|
// 二进制数据聚合(服务端→设备,如 TTS 音频)
|
||||||
speechAccum *speechBuffer
|
speechAccum *speechBuffer
|
||||||
@ -135,7 +147,7 @@ func (b *Bridge) Authorized() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OnCmd 注册命令处理器。当收到 remotedevice 下发的 cmd 时调用。
|
// OnCmd 注册命令处理器。当收到 remotedevice 下发的 cmd 时调用。
|
||||||
func (b *Bridge) OnCmd(handler CmdHandler) {
|
func (b *Bridge) OnCmd(handler BridgeCmdHandler) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
b.cmdHandler = handler
|
b.cmdHandler = handler
|
||||||
@ -155,6 +167,13 @@ func (b *Bridge) OnData(handler DataHandler) {
|
|||||||
b.dataHandler = handler
|
b.dataHandler = handler
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OnPush 注册 agent 主动投递内容的回调(服务端 op=push)。
|
||||||
|
func (b *Bridge) OnPush(handler PushHandler) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
b.pushHandler = handler
|
||||||
|
}
|
||||||
|
|
||||||
// SetPingInterval 设置心跳间隔(默认 30 秒)。
|
// SetPingInterval 设置心跳间隔(默认 30 秒)。
|
||||||
func (b *Bridge) SetPingInterval(d time.Duration) {
|
func (b *Bridge) SetPingInterval(d time.Duration) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
@ -436,7 +455,7 @@ func (b *Bridge) handleMessage(msg map[string]interface{}) {
|
|||||||
log.Printf("[devicebridge] cmd req=%s type=%s cmd=%s", reqID, cmdType, truncateString(command, 60))
|
log.Printf("[devicebridge] cmd req=%s type=%s cmd=%s", reqID, cmdType, truncateString(command, 60))
|
||||||
|
|
||||||
if handler != nil {
|
if handler != nil {
|
||||||
handler(reqID, command)
|
handler(reqID, command, cmdType)
|
||||||
}
|
}
|
||||||
|
|
||||||
case "hello_ack", "bind_ack":
|
case "hello_ack", "bind_ack":
|
||||||
@ -476,6 +495,25 @@ func (b *Bridge) handleMessage(msg map[string]interface{}) {
|
|||||||
dh(reqID, acc.kind, acc.mime, data)
|
dh(reqID, acc.kind, acc.mime, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "push":
|
||||||
|
// agent 主动投递(output_send__device/<id>)。二进制负载走
|
||||||
|
// cmd_speech_* → DataHandler,这里只处理文本/结构化。
|
||||||
|
reqID, _ := msg["req_id"].(string)
|
||||||
|
typ, _ := msg["type"].(string)
|
||||||
|
payload, _ := msg["payload"].(string)
|
||||||
|
meta, _ := msg["meta"].(string)
|
||||||
|
if typ == "" {
|
||||||
|
typ = "text"
|
||||||
|
}
|
||||||
|
b.mu.RLock()
|
||||||
|
ph := b.pushHandler
|
||||||
|
b.mu.RUnlock()
|
||||||
|
if ph != nil {
|
||||||
|
ph(reqID, typ, payload, meta)
|
||||||
|
} else {
|
||||||
|
log.Printf("[devicebridge] push req=%s type=%s payload=%s", reqID, typ, truncateString(payload, 120))
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
log.Printf("[devicebridge] unhandled op=%s", op)
|
log.Printf("[devicebridge] unhandled op=%s", op)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,12 +9,15 @@ import (
|
|||||||
type EventType string
|
type EventType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
EventRawInput EventType = "raw_input"
|
EventRawInput EventType = "raw_input"
|
||||||
EventAgentOutput EventType = "agent_output"
|
EventAgentOutput EventType = "agent_output"
|
||||||
EventAgentLLMChain EventType = "agent_llm_chain"
|
EventAgentLLMChain EventType = "agent_llm_chain"
|
||||||
EventToolCall EventType = "tool_call"
|
EventToolCall EventType = "tool_call"
|
||||||
EventReasoning EventType = "reasoning"
|
EventReasoning EventType = "reasoning"
|
||||||
EventStage EventType = "stage"
|
EventStage EventType = "stage"
|
||||||
|
// EventScheduler 是输入调度器的状态变更事件(抢占/挂起/恢复),
|
||||||
|
// 供状态页与诊断订阅(设计文档 §11 O2)。
|
||||||
|
EventScheduler EventType = "scheduler"
|
||||||
EventSystem EventType = "system"
|
EventSystem EventType = "system"
|
||||||
EventTerminalOutput EventType = "terminal_output"
|
EventTerminalOutput EventType = "terminal_output"
|
||||||
|
|
||||||
|
|||||||
@ -71,6 +71,29 @@ func NewGraphDB(dbPath string) (*GraphDB, error) {
|
|||||||
return g, nil
|
return g, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenGraphDBReadOnly 以**受限句柄**打开图库:可读、可恢复 WAL,但**一切写入被拒**。
|
||||||
|
//
|
||||||
|
// 这是"子 agent 改不了主记忆"的**结构性**保证(设计 docs/zh/resident-subagent-design.md
|
||||||
|
// §5):不是靠调用方自觉不写,而是把写入在 SQLite 这一层就关掉
|
||||||
|
// (`PRAGMA query_only=1` —— 任何 INSERT/UPDATE/DELETE 都会直接报错)。
|
||||||
|
//
|
||||||
|
// 为什么不用 DSN 的 `mode=ro`:只读模式的连接在 WAL 库上无法自行恢复 -wal,
|
||||||
|
// 而主库在父 agent 手里是持续写入的。query_only 让连接保持正常打开能力,
|
||||||
|
// 同时**只堵写**,语义正是我们要的。
|
||||||
|
//
|
||||||
|
// 注意:不建表、不迁移 —— 受限句柄假定库已存在(由父 agent 建好)。
|
||||||
|
func OpenGraphDBReadOnly(dbPath string) (*GraphDB, error) {
|
||||||
|
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=on")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open graph db (readonly): %w", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec("PRAGMA query_only=1"); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, fmt.Errorf("set query_only: %w", err)
|
||||||
|
}
|
||||||
|
return &GraphDB{db: db, dbPath: dbPath}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (g *GraphDB) initSchema() error {
|
func (g *GraphDB) initSchema() error {
|
||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
defer g.mu.Unlock()
|
defer g.mu.Unlock()
|
||||||
@ -233,6 +256,9 @@ func (g *GraphDB) migrateRelationUnique(tx *sql.Tx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Commit 把三元组写入图库,返回新建的实体数与关系数。
|
// Commit 把三元组写入图库,返回新建的实体数与关系数。
|
||||||
|
// Path 返回本库的存储路径(父 agent 用它为驻留子打开**受限句柄**)。
|
||||||
|
func (g *GraphDB) Path() string { return g.dbPath }
|
||||||
|
|
||||||
func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) {
|
func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) {
|
||||||
_, ec, rc, err := g.commit(triples, sessionID, turnID, false)
|
_, ec, rc, err := g.commit(triples, sessionID, turnID, false)
|
||||||
return ec, rc, err
|
return ec, rc, err
|
||||||
@ -603,6 +629,46 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportTriples 导出库中的**活跃**三元组(供父 agent 在回收阶段收割子的 temp)。
|
||||||
|
//
|
||||||
|
// 设计 docs/zh/resident-subagent-design.md §9(回收 = 父读 temp → 选记录 → 合入 main)。
|
||||||
|
// 只导出 `status='active'` 的关系,并把实体名一并带出(Relation 已含 SourceName/TargetName),
|
||||||
|
// 于是合入侧可以直接复用 Commit —— 它按实体名 upsert、按
|
||||||
|
// (source, relation, target) 幂等,因此"重复收割"不会造成重复条目。
|
||||||
|
//
|
||||||
|
// limit <= 0 表示不限量。
|
||||||
|
func (g *GraphDB) ExportTriples(limit int) ([]Triple, error) {
|
||||||
|
g.mu.RLock()
|
||||||
|
defer g.mu.RUnlock()
|
||||||
|
|
||||||
|
q := `SELECT s.name, r.relation_type, t.name, r.confidence
|
||||||
|
FROM relations r
|
||||||
|
JOIN entities s ON s.id = r.source_id
|
||||||
|
JOIN entities t ON t.id = r.target_id
|
||||||
|
WHERE r.status = 'active'
|
||||||
|
ORDER BY r.id`
|
||||||
|
args := []interface{}{}
|
||||||
|
if limit > 0 {
|
||||||
|
q += " LIMIT ?"
|
||||||
|
args = append(args, limit)
|
||||||
|
}
|
||||||
|
rows, err := g.db.Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Triple
|
||||||
|
for rows.Next() {
|
||||||
|
var tr Triple
|
||||||
|
if err := rows.Scan(&tr.Subject, &tr.Relation, &tr.Object, &tr.Confidence); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, tr)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) {
|
func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) {
|
||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
defer g.mu.Unlock()
|
defer g.mu.Unlock()
|
||||||
|
|||||||
81
internal/memory/graph_readonly_test.go
Normal file
81
internal/memory/graph_readonly_test.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
// N2a 第一块砖:**受限句柄**(query_only)—— 子是"读得到主记忆、写不进去"的结构性保证。
|
||||||
|
//
|
||||||
|
// 设计:docs/zh/resident-subagent-design.md §5(两级空间)与 §5.5(子的记忆面)。
|
||||||
|
// 轻量内核拿的是「主库受限句柄(只读)+ 自己的 temp 实例(读写)」,
|
||||||
|
// 于是"子改不了 main"不靠调用方自觉,而是被 SQLite 直接拒。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpenGraphDBReadOnly_ReadsWorkWritesRejected(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "main.db")
|
||||||
|
|
||||||
|
// 父 agent 先建库并写入数据。
|
||||||
|
main, err := NewGraphDB(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := main.Commit([]Triple{{
|
||||||
|
Subject: "张三", Relation: "任职于", Object: "某公司",
|
||||||
|
}}, "sess-1", 1); err != nil {
|
||||||
|
t.Fatalf("父写入失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 子拿到受限句柄。
|
||||||
|
ro, err := OpenGraphDBReadOnly(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("打开受限句柄失败: %v", err)
|
||||||
|
}
|
||||||
|
defer ro.Close()
|
||||||
|
|
||||||
|
// ① 读得到。
|
||||||
|
res, err := ro.Recall([]string{"张三"}, nil, 1, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("受限句柄应能读: %v", err)
|
||||||
|
}
|
||||||
|
if res == nil {
|
||||||
|
t.Fatal("读取结果不应为 nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 写不进去 —— 结构性拒绝(不是"约定不写")。
|
||||||
|
if _, _, err := ro.Commit([]Triple{{
|
||||||
|
Subject: "李四", Relation: "任职于", Object: "另一公司",
|
||||||
|
}}, "sess-2", 1); err == nil {
|
||||||
|
t.Fatal("受限句柄的写入必须被 SQLite 拒绝(query_only)")
|
||||||
|
}
|
||||||
|
if _, err := ro.Purge(map[string]string{"subject": "张三"}, "soft"); err == nil {
|
||||||
|
t.Fatal("受限句柄的 Purge 必须被拒")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 父库不受影响:子写失败没留下任何痕迹。
|
||||||
|
again, err := main.Recall([]string{"李四"}, nil, 1, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if again != nil && len(again.Entities) > 0 {
|
||||||
|
t.Fatalf("子写失败却在主库留下了痕迹:%+v", again.Entities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 受限句柄不建表、不迁移:库不存在时按只读语义处理(查不到东西),
|
||||||
|
// 但**不得**悄悄创建出一个空库(否则"子"会凭空造出主库)。
|
||||||
|
func TestOpenGraphDBReadOnly_DoesNotCreateSchema(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "empty.db")
|
||||||
|
|
||||||
|
ro, err := OpenGraphDBReadOnly(path)
|
||||||
|
if err != nil {
|
||||||
|
// 打开本身允许失败(某些平台会因文件不存在直接报错)——
|
||||||
|
// 关键是"不得建表成功"。
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer ro.Close()
|
||||||
|
if _, err := ro.Recall([]string{"任意"}, nil, 1, ""); err == nil {
|
||||||
|
t.Fatal("库不存在时受限句柄的查询应当报错,而不是凭空建表后返回空结果")
|
||||||
|
}
|
||||||
|
}
|
||||||
191
internal/memory/light_memory.go
Normal file
191
internal/memory/light_memory.go
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
// 轻量内核的**图记忆装配**(设计 §5.6):
|
||||||
|
//
|
||||||
|
// temp 实例(独立存储,读写) ← 子的一切图记忆写入落这里,与子同生共死
|
||||||
|
// 主库**受限句柄**(只读) ← 子只能读;写入被 SQLite 结构性拒绝
|
||||||
|
//
|
||||||
|
// 子的查询 = 两个实例各查一次,**应用层合并**(并集):
|
||||||
|
// - 实体:按名字去重(同名视为同一实体,保留 mention_count 较大的一条)
|
||||||
|
// - 关系:按 (源名, 关系, 目标名) 去重
|
||||||
|
//
|
||||||
|
// 为什么合并放在应用层而不是给共享记忆层加 space 列:**独立存储实例**让隔离成为
|
||||||
|
// 结构性的(不同库),不依赖 where 条件;代价就是这个合并函数。
|
||||||
|
//
|
||||||
|
// 回收(N2d)由父 agent 读 `Temp()` 并选出要保留的记录写进主库。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LightMemory 是驻留子的图记忆装配。
|
||||||
|
type LightMemory struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
|
||||||
|
// temp 是子自己的图记忆实例(独立存储)。当不允许子写图记忆时为 nil。
|
||||||
|
temp *GraphDB
|
||||||
|
// main 是主图记忆的受限句柄(只读)。
|
||||||
|
main *GraphDB
|
||||||
|
|
||||||
|
// allowWrite 报告是否允许写 temp(false ⇒ 子对图记忆完全只读)。
|
||||||
|
allowWrite bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLightMemory 构造子的图记忆装配。
|
||||||
|
//
|
||||||
|
// - main: 主库句柄。为 nil 时表示"只能用自己的 temp"(一般不该发生)。
|
||||||
|
// - tempPath: 子 temp 实例的存储路径(独立文件)。allowWrite=false 时**不会**打开它。
|
||||||
|
// - allowWrite: 是否允许子写图记忆(用户给的备选开关,默认建议 true)。
|
||||||
|
func NewLightMemory(main *GraphDB, tempPath string, allowWrite bool) (*LightMemory, error) {
|
||||||
|
m := &LightMemory{main: main, allowWrite: allowWrite}
|
||||||
|
if !allowWrite {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if tempPath == "" {
|
||||||
|
return nil, fmt.Errorf("允许写图记忆时必须给出 temp 存储路径")
|
||||||
|
}
|
||||||
|
temp, err := NewGraphDB(tempPath) // temp 是子自己的库:建表/迁移都正常
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open temp graph db: %w", err)
|
||||||
|
}
|
||||||
|
m.temp = temp
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowWrite 报告子能否写图记忆。
|
||||||
|
func (m *LightMemory) AllowWrite() bool { return m.allowWrite }
|
||||||
|
|
||||||
|
// Temp 返回子的 temp 实例(可能为 nil)。回收时父 agent 用它读取/收割。
|
||||||
|
func (m *LightMemory) Temp() *GraphDB {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.temp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main 返回主库的受限句柄。
|
||||||
|
func (m *LightMemory) Main() *GraphDB { return m.main }
|
||||||
|
|
||||||
|
// Commit 写入图记忆:**只落 temp**(设计 §5:写目标收窄到自己的空间)。
|
||||||
|
func (m *LightMemory) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) {
|
||||||
|
if !m.allowWrite {
|
||||||
|
return 0, 0, fmt.Errorf("本 agent 不允许写图记忆(allowTempGraphWrite=false)")
|
||||||
|
}
|
||||||
|
m.mu.RLock()
|
||||||
|
temp := m.temp
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if temp == nil {
|
||||||
|
return 0, 0, fmt.Errorf("temp 图记忆实例不可用")
|
||||||
|
}
|
||||||
|
return temp.Commit(triples, sessionID, turnID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recall 在 temp ∪ main 上做召回(并集),按上述规则合并去重。
|
||||||
|
//
|
||||||
|
// 任一侧出错都不影响另一侧的结果:单侧失败只在两侧都失败时返回错误
|
||||||
|
// (主库是只读句柄,任何"查询即失败"都说明是真实故障)。
|
||||||
|
func (m *LightMemory) Recall(keywords []string, seedEntities []string, depth int, sessionFilter string) (*RecallResult, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
temp := m.temp
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
var (
|
||||||
|
parts []*RecallResult
|
||||||
|
lastErr error
|
||||||
|
okAny bool
|
||||||
|
)
|
||||||
|
if temp != nil {
|
||||||
|
r, err := temp.Recall(keywords, seedEntities, depth, sessionFilter)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
} else {
|
||||||
|
okAny = true
|
||||||
|
parts = append(parts, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.main != nil {
|
||||||
|
r, err := m.main.Recall(keywords, seedEntities, depth, sessionFilter)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
} else {
|
||||||
|
okAny = true
|
||||||
|
parts = append(parts, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !okAny {
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("没有可用的图记忆实例")
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
return mergeRecall(parts...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeRecall 把多个来源的召回结果并成一份(实体按名字、关系按三元组去重)。
|
||||||
|
//
|
||||||
|
// 顺序确定(先 entities/relations 各自排序),便于测试与展示稳定。
|
||||||
|
func mergeRecall(parts ...*RecallResult) *RecallResult {
|
||||||
|
out := &RecallResult{}
|
||||||
|
seenEntity := map[string]int{} // 小写名 → out.Entities 下标
|
||||||
|
for _, p := range parts {
|
||||||
|
if p == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range p.Entities {
|
||||||
|
k := strings.ToLower(strings.TrimSpace(e.Name))
|
||||||
|
if k == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i, dup := seenEntity[k]; dup {
|
||||||
|
// 同名实体:保留 mention_count 较大的一条(更新的那个)。
|
||||||
|
if e.MentionCount > out.Entities[i].MentionCount {
|
||||||
|
out.Entities[i] = e
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenEntity[k] = len(out.Entities)
|
||||||
|
out.Entities = append(out.Entities, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seenRel := map[string]struct{}{}
|
||||||
|
for _, p := range parts {
|
||||||
|
if p == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, r := range p.Relations {
|
||||||
|
k := strings.ToLower(strings.TrimSpace(r.SourceName)) + "\x00" +
|
||||||
|
strings.ToLower(strings.TrimSpace(r.RelationType)) + "\x00" +
|
||||||
|
strings.ToLower(strings.TrimSpace(r.TargetName))
|
||||||
|
if _, dup := seenRel[k]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenRel[k] = struct{}{}
|
||||||
|
out.Relations = append(out.Relations, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out.Entities, func(i, j int) bool { return out.Entities[i].Name < out.Entities[j].Name })
|
||||||
|
sort.Slice(out.Relations, func(i, j int) bool {
|
||||||
|
if out.Relations[i].SourceName != out.Relations[j].SourceName {
|
||||||
|
return out.Relations[i].SourceName < out.Relations[j].SourceName
|
||||||
|
}
|
||||||
|
if out.Relations[i].RelationType != out.Relations[j].RelationType {
|
||||||
|
return out.Relations[i].RelationType < out.Relations[j].RelationType
|
||||||
|
}
|
||||||
|
return out.Relations[i].TargetName < out.Relations[j].TargetName
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭 temp 实例(主库句柄由父 agent 拥有,不在这里关)。
|
||||||
|
func (m *LightMemory) Close() error {
|
||||||
|
m.mu.Lock()
|
||||||
|
temp := m.temp
|
||||||
|
m.temp = nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
if temp != nil {
|
||||||
|
return temp.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
208
internal/memory/light_memory_test.go
Normal file
208
internal/memory/light_memory_test.go
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
// N2a 第二块砖:轻量内核的图记忆装配(temp 可写 + 主库只读 + 并集查询)。
|
||||||
|
//
|
||||||
|
// 设计:docs/zh/resident-subagent-design.md §5.6。
|
||||||
|
// 关键性质:
|
||||||
|
// ① 子写入只落 temp(写不进展主库)—— main 是受限句柄,结构性拒绝;
|
||||||
|
// ② 子查询 = temp ∪ main;
|
||||||
|
// ③ 两个子之间 temp 互不可见;
|
||||||
|
// ④ allowTempGraphWrite=false 时子对图记忆完全只读(没有 temp 实例)。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newMainWith 建一个主库并写入若干三元组。
|
||||||
|
func newMainWith(t *testing.T, dir string, triples ...Triple) *GraphDB {
|
||||||
|
t.Helper()
|
||||||
|
main, err := NewGraphDB(filepath.Join(dir, "main.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(triples) > 0 {
|
||||||
|
if _, _, err := main.Commit(triples, "sess", 1); err != nil {
|
||||||
|
t.Fatalf("主库写入失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return main
|
||||||
|
}
|
||||||
|
|
||||||
|
func names(entities []Entity) []string {
|
||||||
|
out := make([]string, 0, len(entities))
|
||||||
|
for _, e := range entities {
|
||||||
|
out = append(out, e.Name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasName(entities []Entity, name string) bool {
|
||||||
|
for _, e := range entities {
|
||||||
|
if e.Name == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 子写入只落 temp;查询是 temp ∪ main。
|
||||||
|
func TestLightMemory_WriteGoesToTempReadIsUnion(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
main := newMainWith(t, dir, Triple{Subject: "张三", Relation: "任职于", Object: "甲公司"})
|
||||||
|
defer main.Close()
|
||||||
|
|
||||||
|
light, err := NewLightMemory(main, filepath.Join(dir, "sub-1.db"), true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer light.Close()
|
||||||
|
|
||||||
|
// 子写自己的发现。
|
||||||
|
if _, _, err := light.Commit([]Triple{{Subject: "李四", Relation: "任职于", Object: "乙公司"}}, "sess", 1); err != nil {
|
||||||
|
t.Fatalf("子写入 temp 应成功: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询能同时看到 main(张三)与 temp(李四)。
|
||||||
|
res, err := light.Recall([]string{"张三", "李四"}, nil, 1, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !hasName(res.Entities, "张三") {
|
||||||
|
t.Fatalf("子应看得到主库内容,实际 %v", names(res.Entities))
|
||||||
|
}
|
||||||
|
if !hasName(res.Entities, "李四") {
|
||||||
|
t.Fatalf("子应看得到自己 temp 的内容,实际 %v", names(res.Entities))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主库**没有**被写入(子改不了 main)。
|
||||||
|
mainOnly, err := main.Recall([]string{"李四"}, nil, 1, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hasName(mainOnly.Entities, "李四") {
|
||||||
|
t.Fatalf("主的图记忆里不该出现子才知道的实体:%v", names(mainOnly.Entities))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 两个子之间 temp 互不可见(只有 main 共享)。
|
||||||
|
func TestLightMemory_ChildrenTempsAreIsolated(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
main := newMainWith(t, dir, Triple{Subject: "共享", Relation: "属于", Object: "主库"})
|
||||||
|
defer main.Close()
|
||||||
|
|
||||||
|
a, err := NewLightMemory(main, filepath.Join(dir, "sub-a.db"), true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer a.Close()
|
||||||
|
b, err := NewLightMemory(main, filepath.Join(dir, "sub-b.db"), true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer b.Close()
|
||||||
|
|
||||||
|
if _, _, err := a.Commit([]Triple{{Subject: "A的秘密", Relation: "仅属于", Object: "A"}}, "s", 1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resB, err := b.Recall([]string{"A的秘密", "共享"}, nil, 1, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hasName(resB.Entities, "A的秘密") {
|
||||||
|
t.Fatalf("B 不该看到 A 的 temp:%v", names(resB.Entities))
|
||||||
|
}
|
||||||
|
if !hasName(resB.Entities, "共享") {
|
||||||
|
t.Fatalf("B 应看得到共享的 main:%v", names(resB.Entities))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowTempGraphWrite=false:子对图记忆完全只读(没有 temp 实例,写入被拒)。
|
||||||
|
func TestLightMemory_WriteDisabledIsFullyReadOnly(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
main := newMainWith(t, dir, Triple{Subject: "张三", Relation: "任职于", Object: "甲公司"})
|
||||||
|
defer main.Close()
|
||||||
|
|
||||||
|
light, err := NewLightMemory(main, "", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("不允许写时不应要求 temp 路径: %v", err)
|
||||||
|
}
|
||||||
|
defer light.Close()
|
||||||
|
|
||||||
|
if light.AllowWrite() {
|
||||||
|
t.Fatal("AllowWrite 应为 false")
|
||||||
|
}
|
||||||
|
if light.Temp() != nil {
|
||||||
|
t.Fatal("不允许写时不该有 temp 实例")
|
||||||
|
}
|
||||||
|
if _, _, err := light.Commit([]Triple{{Subject: "李四", Relation: "x", Object: "y"}}, "s", 1); err == nil {
|
||||||
|
t.Fatal("不允许写时的写入必须被拒")
|
||||||
|
}
|
||||||
|
// 读仍然可用(只读主库)。
|
||||||
|
res, err := light.Recall([]string{"张三"}, nil, 1, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !hasName(res.Entities, "张三") {
|
||||||
|
t.Fatalf("只读模式仍应读到主库:%v", names(res.Entities))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并集必须去重:同一个实体同时存在于 main 与 temp 时只出现一次。
|
||||||
|
func TestLightMemory_UnionDedupesSameEntity(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
main := newMainWith(t, dir, Triple{Subject: "张三", Relation: "任职于", Object: "甲公司"})
|
||||||
|
defer main.Close()
|
||||||
|
|
||||||
|
light, err := NewLightMemory(main, filepath.Join(dir, "sub.db"), true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer light.Close()
|
||||||
|
if _, _, err := light.Commit([]Triple{{Subject: "张三", Relation: "擅长", Object: "Go"}}, "s", 1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := light.Recall([]string{"张三"}, nil, 1, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, e := range res.Entities {
|
||||||
|
if e.Name == "张三" {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("同名实体在并集中应只出现一次,实际 %d 次:%v", n, names(res.Entities))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并集查询的确定性(同样的输入必得同样的顺序),便于断言与展示稳定。
|
||||||
|
func TestMergeRecall_Deterministic(t *testing.T) {
|
||||||
|
a := &RecallResult{
|
||||||
|
Entities: []Entity{{Name: "b", MentionCount: 1}, {Name: "a", MentionCount: 1}},
|
||||||
|
Relations: []Relation{
|
||||||
|
{SourceName: "z", RelationType: "r", TargetName: "y"},
|
||||||
|
{SourceName: "a", RelationType: "r", TargetName: "b"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
b := &RecallResult{
|
||||||
|
Entities: []Entity{{Name: "a", MentionCount: 5}},
|
||||||
|
Relations: []Relation{{SourceName: "a", RelationType: "r", TargetName: "b"}},
|
||||||
|
}
|
||||||
|
got := mergeRecall(a, b)
|
||||||
|
if len(got.Entities) != 2 || got.Entities[0].Name != "a" || got.Entities[1].Name != "b" {
|
||||||
|
t.Fatalf("实体合并结果=%v", names(got.Entities))
|
||||||
|
}
|
||||||
|
if got.Entities[0].MentionCount != 5 {
|
||||||
|
t.Fatalf("同名实体应保留 mention_count 较大者,实际 %d", got.Entities[0].MentionCount)
|
||||||
|
}
|
||||||
|
if len(got.Relations) != 2 {
|
||||||
|
t.Fatalf("关系应去重后剩 2 条,实际 %d", len(got.Relations))
|
||||||
|
}
|
||||||
|
if got.Relations[0].SourceName != "a" {
|
||||||
|
t.Fatalf("关系未按源名排序:%+v", got.Relations)
|
||||||
|
}
|
||||||
|
}
|
||||||
117
internal/memory/reclaim_test.go
Normal file
117
internal/memory/reclaim_test.go
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
// N2d 数据面:**回收**=父读子的 temp → 选记录 → 合入 main。
|
||||||
|
//
|
||||||
|
// 设计 §9:回收是"取消语义"(收割后取消该驻留子),且**由父决定纳入哪些**。
|
||||||
|
// 这里只做数据面(读得到、合得进、幂等),控制面(谁来决定、何时取消)在 N3/N6。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExportTriples_OnlyActiveWithNames(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
g, err := NewGraphDB(filepath.Join(dir, "g.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer g.Close()
|
||||||
|
|
||||||
|
if _, _, err := g.Commit([]Triple{
|
||||||
|
{Subject: "张三", Relation: "任职于", Object: "甲公司"},
|
||||||
|
{Subject: "李四", Relation: "合作", Object: "王五"},
|
||||||
|
}, "sess", 1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := g.ExportTriples(0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("导出 %d 条,期望 2:%+v", len(got), got)
|
||||||
|
}
|
||||||
|
// 实体名必须带出来(合入侧要靠名字 upsert,而不是内部 id)。
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, tr := range got {
|
||||||
|
if tr.Subject == "" || tr.Object == "" || tr.Relation == "" {
|
||||||
|
t.Fatalf("导出的三元组字段不全:%+v", tr)
|
||||||
|
}
|
||||||
|
seen[tr.Subject+"→"+tr.Object] = true
|
||||||
|
}
|
||||||
|
if !seen["张三→甲公司"] || !seen["李四→王五"] {
|
||||||
|
t.Fatalf("导出内容不对:%+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// limit 生效。
|
||||||
|
one, err := g.ExportTriples(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(one) != 1 {
|
||||||
|
t.Fatalf("limit=1 应导出 1 条,实际 %d", len(one))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回收主流程:子写 temp → 父导出 → 选中的合入 main;主库拿到、temp 不变。
|
||||||
|
func TestReclaim_HarvestChildTempIntoMain(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
main, err := NewGraphDB(filepath.Join(dir, "main.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer main.Close()
|
||||||
|
|
||||||
|
child, err := NewLightMemory(main, filepath.Join(dir, "sub.db"), true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer child.Close()
|
||||||
|
|
||||||
|
// 子在 temp 里积累了两条发现。
|
||||||
|
if _, _, err := child.Commit([]Triple{
|
||||||
|
{Subject: "子发现A", Relation: "指向", Object: "结论1"},
|
||||||
|
{Subject: "子发现B", Relation: "指向", Object: "结论2"},
|
||||||
|
}, "sess", 1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 父:导出 → **选一条**("哪些纳入记忆"由父决定)→ 写进 main。
|
||||||
|
all, err := child.Temp().ExportTriples(0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(all) != 2 {
|
||||||
|
t.Fatalf("导出 %d 条,期望 2", len(all))
|
||||||
|
}
|
||||||
|
selected := []Triple{all[0]}
|
||||||
|
if _, _, err := main.Commit(selected, "reclaim", 0); err != nil {
|
||||||
|
t.Fatalf("合入 main 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主库只拿到选中的那条。
|
||||||
|
mainRes, err := main.Recall([]string{"子发现A", "子发现B"}, nil, 1, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !hasName(mainRes.Entities, "子发现A") {
|
||||||
|
t.Fatalf("选中的记录应进主库:%v", names(mainRes.Entities))
|
||||||
|
}
|
||||||
|
if hasName(mainRes.Entities, "子发现B") {
|
||||||
|
t.Fatalf("未选中的记录不该进主库:%v", names(mainRes.Entities))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重复收割是幂等的(Commit 按实体名 upsert + 关系唯一约束)。
|
||||||
|
before := len(mainRes.Entities)
|
||||||
|
if _, _, err := main.Commit(selected, "reclaim", 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, err := main.Recall([]string{"子发现A"}, nil, 1, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(after.Entities) != before {
|
||||||
|
t.Fatalf("重复收割不应产生重复实体:before=%d after=%d", before, len(after.Entities))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -21,10 +21,14 @@ var (
|
|||||||
// 新增的注入行为标志位(InjectOptions:no_memory / context_policy)
|
// 新增的注入行为标志位(InjectOptions:no_memory / context_policy)
|
||||||
// 与 ChannelDef.ContextPolicy,使输入/排队注入/中断注入/同步注入都能
|
// 与 ChannelDef.ContextPolicy,使输入/排队注入/中断注入/同步注入都能
|
||||||
// 声明「是否记入记忆」与「是否据此裁剪上下文」(默认都是否)。
|
// 声明「是否记入记忆」与「是否据此裁剪上下文」(默认都是否)。
|
||||||
|
// 1.3.0:注入优先级贯通(SDK 1.3.0 的 InjectOptions.Priority 与
|
||||||
|
// PriorityL1-L4)——插件可声明自己中断的级别,内核按四级中断阶梯调度;
|
||||||
|
// 并实现 UnregisterOutputChannel,使动态通道(远程设备 device/<id>)
|
||||||
|
// 能随资源生灭,而不是留下死通道骗模型。
|
||||||
//
|
//
|
||||||
// ❗main 上此值始终是**下一个未发布中版本**,不随 patch 发布变动
|
// ❗main 上此值始终是**下一个未发布中版本**,不随 patch 发布变动
|
||||||
//(见 docs/git-branching.md §2.1);已发布的版本号看对应的 release/vX.Y.x 与 tag。
|
//(见 docs/git-branching.md §2.1);已发布的版本号看对应的 release/vX.Y.x 与 tag。
|
||||||
Version = "1.2.2"
|
Version = "1.3.0"
|
||||||
|
|
||||||
// Commit 是构建时的 Git commit hash。
|
// Commit 是构建时的 Git commit hash。
|
||||||
Commit = "unknown"
|
Commit = "unknown"
|
||||||
@ -42,7 +46,10 @@ var (
|
|||||||
// 注入变体、InjectOptions、ChannelDef.ContextPolicy),因此声明为
|
// 注入变体、InjectOptions、ChannelDef.ContextPolicy),因此声明为
|
||||||
// 1.2.0。用 SDK 1.0.0/1.1.0 编的存量插件照旧可用——新增方法由
|
// 1.2.0。用 SDK 1.0.0/1.1.0 编的存量插件照旧可用——新增方法由
|
||||||
// **插件调用、内核实现**,不调就不受影响,无需重编。
|
// **插件调用、内核实现**,不调就不受影响,无需重编。
|
||||||
SDKCompatibleVersion = "1.2.0"
|
// 1.3.0:内核实现了 SDK 1.3.0 的全部新增面 —— InjectOptions.Priority 的
|
||||||
|
// 分级中断调度、PriorityL1-L4 常量的语义,以及 UnregisterOutputChannel
|
||||||
|
// 在通道登记表 + io 设备表上的注销。同样:不调新面就不受影响。
|
||||||
|
SDKCompatibleVersion = "1.3.0"
|
||||||
|
|
||||||
// SourceURL 是本内核构建所对应的源码地址。
|
// SourceURL 是本内核构建所对应的源码地址。
|
||||||
//
|
//
|
||||||
|
|||||||
43
internal/plugin/channel_warn_test.go
Normal file
43
internal/plugin/channel_warn_test.go
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 延迟判定的语义:看的是"插件 Start 结束后最终声明了什么",
|
||||||
|
// 而不是"注册出站通道的那一刻有没有入站声明"。
|
||||||
|
//
|
||||||
|
// 为什么必须这样判:声明顺序自由 —— qq/weather 都是**先** RegisterOutputChannel
|
||||||
|
// **后** RegisterInputChannel,按注册时刻判会把它们误报成"只声明了输出通道"
|
||||||
|
// (实测发生过:用户据此以为 qq 插件没更新)。
|
||||||
|
func TestWarnOutputOnlyChannels(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
oldOut := log.Writer()
|
||||||
|
log.SetOutput(&buf)
|
||||||
|
defer log.SetOutput(oldOut)
|
||||||
|
|
||||||
|
// ① 出站+入站都声明了(先出站后入站)⇒ 不该告警
|
||||||
|
r.noteChannel("qq", "qq", true)
|
||||||
|
r.noteChannel("qq", "qq", false)
|
||||||
|
r.warnOutputOnlyChannels("qq")
|
||||||
|
if s := buf.String(); s != "" {
|
||||||
|
t.Fatalf("qq 声明了入站通道,不应告警,实际: %s", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 只声明出站 ⇒ 应告警,且只报这一个通道
|
||||||
|
buf.Reset()
|
||||||
|
r.noteChannel("weather", "weather_weather_out", true)
|
||||||
|
r.noteChannel("weather", "weather_weather_in", false)
|
||||||
|
r.warnOutputOnlyChannels("weather")
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "weather_weather_out") {
|
||||||
|
t.Fatalf("只声明出站的通道应被告警,实际: %q", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "weather_weather_in") {
|
||||||
|
t.Fatalf("已声明入站的通道不该被牵连,实际: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -181,7 +181,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
|
|||||||
if err := validateContextPolicy("io.injectText", p.ContextPolicy); err != nil {
|
if err := validateContextPolicy("io.injectText", p.ContextPolicy); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
|
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
|
||||||
return nil, nil
|
return nil, nil
|
||||||
case MethodIOInjectInterrupt:
|
case MethodIOInjectInterrupt:
|
||||||
var p injectParams
|
var p injectParams
|
||||||
@ -191,7 +191,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
|
|||||||
if err := validateContextPolicy("io.injectInterrupt", p.ContextPolicy); err != nil {
|
if err := validateContextPolicy("io.injectInterrupt", p.ContextPolicy); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
h.sdk.InjectInterruptTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
|
h.sdk.InjectInterruptTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
|
||||||
return nil, nil
|
return nil, nil
|
||||||
case MethodIOInjectTextNoMem:
|
case MethodIOInjectTextNoMem:
|
||||||
var p injectParams
|
var p injectParams
|
||||||
@ -202,7 +202,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// 旧 RPC 语义就是「不进记忆」,显式标志位只可能再叠上 context_policy。
|
// 旧 RPC 语义就是「不进记忆」,显式标志位只可能再叠上 context_policy。
|
||||||
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(true, p.ContextPolicy, p.CleanerName))
|
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(true, p.ContextPolicy, p.CleanerName, p.Priority))
|
||||||
return nil, nil
|
return nil, nil
|
||||||
case MethodIOInjectSync:
|
case MethodIOInjectSync:
|
||||||
var p injectParams
|
var p injectParams
|
||||||
@ -212,7 +212,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
|
|||||||
if err := validateContextPolicy("io.injectInputSync", p.ContextPolicy); err != nil {
|
if err := validateContextPolicy("io.injectInputSync", p.ContextPolicy); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
reply := h.sdk.InjectInputSyncOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
|
reply := h.sdk.InjectInputSyncOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
|
||||||
return map[string]interface{}{"reply": reply}, nil
|
return map[string]interface{}{"reply": reply}, nil
|
||||||
|
|
||||||
case MethodIOInjectMedia:
|
case MethodIOInjectMedia:
|
||||||
@ -227,7 +227,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
h.sdk.InjectInputMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
|
h.sdk.InjectInputMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|
||||||
case MethodIOInjectMediaSync:
|
case MethodIOInjectMediaSync:
|
||||||
@ -242,7 +242,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
reply := h.sdk.InjectInputMediaSyncOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
|
reply := h.sdk.InjectInputMediaSyncOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
|
||||||
return map[string]interface{}{"reply": reply}, nil
|
return map[string]interface{}{"reply": reply}, nil
|
||||||
|
|
||||||
case MethodIOInjectInterruptMedia:
|
case MethodIOInjectInterruptMedia:
|
||||||
@ -257,7 +257,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
h.sdk.InjectInterruptMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
|
h.sdk.InjectInterruptMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|
||||||
// ---- 生命周期(原 case 8)----
|
// ---- 生命周期(原 case 8)----
|
||||||
@ -695,6 +695,8 @@ type injectParams struct {
|
|||||||
NoMemory bool `json:"no_memory,omitempty"`
|
NoMemory bool `json:"no_memory,omitempty"`
|
||||||
ContextPolicy string `json:"context_policy,omitempty"`
|
ContextPolicy string `json:"context_policy,omitempty"`
|
||||||
CleanerName string `json:"cleaner_name,omitempty"`
|
CleanerName string `json:"cleaner_name,omitempty"`
|
||||||
|
// Priority 声明中断注入的优先级(L1..L3);L4 内核独占,见 InjectOptions。
|
||||||
|
Priority string `json:"priority,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// injectMediaParams 是带媒体注入/工具块注入的参数。
|
// injectMediaParams 是带媒体注入/工具块注入的参数。
|
||||||
@ -715,14 +717,35 @@ type injectMediaParams struct {
|
|||||||
NoMemory bool `json:"no_memory,omitempty"`
|
NoMemory bool `json:"no_memory,omitempty"`
|
||||||
ContextPolicy string `json:"context_policy,omitempty"`
|
ContextPolicy string `json:"context_policy,omitempty"`
|
||||||
CleanerName string `json:"cleaner_name,omitempty"`
|
CleanerName string `json:"cleaner_name,omitempty"`
|
||||||
|
Priority string `json:"priority,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// pubSdkInjectOpts 把 RPC 报文里的三个字段转成公开 SDK 的 InjectOptions。
|
// pubSdkInjectOpts 把 RPC 报文里的三个字段转成公开 SDK 的 InjectOptions。
|
||||||
//
|
//
|
||||||
// 单独提一个转换函数是为了让「默认值」只有一个出处:零值即记入记忆 + 不裁剪,
|
// 单独提一个转换函数是为了让「默认值」只有一个出处:零值即记入记忆 + 不裁剪,
|
||||||
// 与旧三参数注入等价。
|
// 与旧三参数注入等价。
|
||||||
func pubSdkInjectOpts(noMemory bool, policy, cleanerName string) pubsdk.InjectOptions {
|
func pubSdkInjectOpts(noMemory bool, policy, cleanerName, priority string) pubsdk.InjectOptions {
|
||||||
return pubsdk.InjectOptions{NoMemory: noMemory, ContextPolicy: policy, CleanerName: cleanerName}
|
return pubsdk.InjectOptions{
|
||||||
|
NoMemory: noMemory, ContextPolicy: policy, CleanerName: cleanerName,
|
||||||
|
Priority: clampExternalPriority(priority),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clampExternalPriority 把外部插件声明的优先级夹到 L1..L3。
|
||||||
|
//
|
||||||
|
// 走本桥的必然是外部插件(独立进程/动态库),它们**不是内核级插件**,
|
||||||
|
// 因此不能声明 L4——“立即打断”那类能力只属于编译期内置插件(如 WebUI 终止按钮)。
|
||||||
|
//
|
||||||
|
// 为什么在这里夹而不是只在内核里按 source 判:source 是插件自报的字段,
|
||||||
|
// 外部插件可以冒用 "webui" 之名;而本函数所在的位置能确知“这来自外部进程”。
|
||||||
|
// 内核侧的 isKernelLevelSource 是第二道闸(纵深防御)。
|
||||||
|
func clampExternalPriority(priority string) string {
|
||||||
|
switch priority {
|
||||||
|
case "L4", "l4":
|
||||||
|
return pubsdk.PriorityL3
|
||||||
|
default:
|
||||||
|
return priority
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateContextPolicy 校验上下文策略取值,与 tool.register 同一套规则。
|
// validateContextPolicy 校验上下文策略取值,与 tool.register 同一套规则。
|
||||||
|
|||||||
48
internal/plugin/proc/priority_clamp_test.go
Normal file
48
internal/plugin/proc/priority_clamp_test.go
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package proc
|
||||||
|
|
||||||
|
// 外部插件(走 proc 桥的独立进程/动态库)**不是内核级插件**,
|
||||||
|
// 因此不能声明 L4 —— “立即打断”能力只属于编译期内置插件(如 WebUI 终止按钮)。
|
||||||
|
//
|
||||||
|
// 在这里夹取而不是只在内核里按 source 判,是因为 source 是插件自报字段、可以冒名;
|
||||||
|
// 本函数所在位置能确知“这来自外部进程”。内核侧的 isKernelLevelSource 是第二道闸。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClampExternalPriority_RejectsL4(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"L4", pubsdk.PriorityL3}, // 越权 → 夹到 L3
|
||||||
|
{"l4", pubsdk.PriorityL3}, // 大小写都要夹
|
||||||
|
{"L3", pubsdk.PriorityL3},
|
||||||
|
{"L2", pubsdk.PriorityL2},
|
||||||
|
{"L1", pubsdk.PriorityL1},
|
||||||
|
{"", ""}, // 未声明保持空(内核按默认级处理)
|
||||||
|
{"紧急", "紧急"}, // 未知值原样传给内核,由内核降级为 L1 并留痕
|
||||||
|
{"L9", "L9"}, // 同上
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := clampExternalPriority(c.in); got != c.want {
|
||||||
|
t.Fatalf("clampExternalPriority(%q)=%q,期望 %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 贯穿 pubSdkInjectOpts:RPC 报文里的 priority 必须经过夹取才落到 InjectOptions。
|
||||||
|
func TestPubSdkInjectOpts_ClampsPriority(t *testing.T) {
|
||||||
|
got := pubSdkInjectOpts(true, "prune", "cleaner", "L4")
|
||||||
|
if got.Priority != pubsdk.PriorityL3 {
|
||||||
|
t.Fatalf("经桥后的优先级=%q,期望 L3", got.Priority)
|
||||||
|
}
|
||||||
|
if !got.NoMemory || got.ContextPolicy != "prune" || got.CleanerName != "cleaner" {
|
||||||
|
t.Fatalf("其它字段被改动:%+v", got)
|
||||||
|
}
|
||||||
|
if l2 := pubSdkInjectOpts(false, "", "", "L2"); l2.Priority != pubsdk.PriorityL2 {
|
||||||
|
t.Fatalf("L2 应原样通过,实际 %q", l2.Priority)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -303,15 +303,40 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// **兼容网**:插件应当显式 RegisterInputChannel 声明自己的入站通道;
|
||||||
|
// 但历史插件常常只用 RegisterOutputChannel 声明(却用同一个名字注入输入,
|
||||||
|
// 例:cli 只声明输出 "cli" 就用 InjectTextSync("cli", ...) 注入)。
|
||||||
|
// 不兜底的话 inputch 登记表里没有它,"把 inputch 划给驻留子"直接失败
|
||||||
|
// (实测报 `划入 inputch cli: inputch 未注册`)。
|
||||||
|
//
|
||||||
|
// ❗这里**不能**判"是否声明过入站通道"并告警:声明顺序是自由的,
|
||||||
|
// 先 RegisterOutputChannel 再 RegisterInputChannel 是常见写法(qq 就是),
|
||||||
|
// 按此刻的状态判会对它误报(实测:把 qq 报成"只声明了输出通道")。
|
||||||
|
// 真正该问的问题是"插件 Start 结束后,这个出站通道有没有对应的入站声明" ——
|
||||||
|
// 那在 load 完成后统一判(见 warnOutputOnlyChannels)。
|
||||||
|
if _, ok := r.iom.LookupInputChannel(chName); !ok {
|
||||||
|
_ = r.iom.RegisterInputChannelFrom(name, chName, agentIO.ChannelDef(def))
|
||||||
|
}
|
||||||
r.noteChannel(name, chName, true)
|
r.noteChannel(name, chName, true)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
regOutputUnreg := func(chName string) error {
|
||||||
|
if r.iom == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r.iom.UnregisterDevice(chName)
|
||||||
|
r.forgetChannel(name, chName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
regInput := func(chName string, def sdk.ChannelDef) error {
|
regInput := func(chName string, def sdk.ChannelDef) error {
|
||||||
if r.iom == nil {
|
if r.iom == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
r.iom.RegisterInputChannel(chName, agentIO.ChannelDef(def))
|
// inputch 是最基本的输入路由单位:登记**归属插件**,便于父 agent 看清
|
||||||
|
// "哪个插件的哪个 inputch 划给了谁"(一个插件可注册多个 inputch)。
|
||||||
|
_ = r.iom.RegisterInputChannelFrom(name, chName, agentIO.ChannelDef(def))
|
||||||
r.noteChannel(name, chName, false)
|
r.noteChannel(name, chName, false)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -321,18 +346,19 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
|
|||||||
EventBus: r.evBus,
|
EventBus: r.evBus,
|
||||||
// 带 media 的包装:插件提交的三元组/文档/文本事件里的媒体会落进 CAS
|
// 带 media 的包装:插件提交的三元组/文档/文本事件里的媒体会落进 CAS
|
||||||
// 并挂上引用。传入插件名仅用于日志溯源(哪个插件写的媒体)。
|
// 并挂上引用。传入插件名仅用于日志溯源(哪个插件写的媒体)。
|
||||||
Memory: sdk.NewGraphMemoryWithMedia(name, r.memDB, r.mediaStore),
|
Memory: sdk.NewGraphMemoryWithMedia(name, r.memDB, r.mediaStore),
|
||||||
TextMemory: sdk.NewTextMemoryWithMedia(name, r.textMem, r.mediaStore),
|
TextMemory: sdk.NewTextMemoryWithMedia(name, r.textMem, r.mediaStore),
|
||||||
DocMemory: sdk.NewDocMemoryWithMedia(name, r.docStore, r.mediaStore),
|
DocMemory: sdk.NewDocMemoryWithMedia(name, r.docStore, r.mediaStore),
|
||||||
Knowledge: sdk.NewKnowledge(r.ks),
|
Knowledge: sdk.NewKnowledge(r.ks),
|
||||||
LLM: sdk.NewLLM(r.mgr, r.cfgReg, r.lua, r.baseKey),
|
LLM: sdk.NewLLM(r.mgr, r.cfgReg, r.lua, r.baseKey),
|
||||||
Settings: sett,
|
Settings: sett,
|
||||||
RegTool: regTool,
|
RegTool: regTool,
|
||||||
RegStage: regStage,
|
RegStage: regStage,
|
||||||
RegAPI: regAPI,
|
RegAPI: regAPI,
|
||||||
RegOutput: regOutput,
|
RegOutput: regOutput,
|
||||||
RegInput: regInput,
|
RegOutputUnreg: regOutputUnreg,
|
||||||
PluginMgr: r,
|
RegInput: regInput,
|
||||||
|
PluginMgr: r,
|
||||||
|
|
||||||
Status: r.status,
|
Status: r.status,
|
||||||
Supervisor: r.sup,
|
Supervisor: r.sup,
|
||||||
@ -429,6 +455,7 @@ func (r *Registry) Load(dir string) error {
|
|||||||
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
|
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
|
||||||
r.instances = append(r.instances, p)
|
r.instances = append(r.instances, p)
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
|
r.warnOutputOnlyChannels(name)
|
||||||
log.Printf("[plugin] loaded: %s", name)
|
log.Printf("[plugin] loaded: %s", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -522,6 +549,7 @@ func (r *Registry) loadOne(plgDir, name string) bool {
|
|||||||
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
|
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
|
||||||
r.sdkRefs[name] = plgSDK
|
r.sdkRefs[name] = plgSDK
|
||||||
r.instances = append(r.instances, plg)
|
r.instances = append(r.instances, plg)
|
||||||
|
r.warnOutputOnlyChannels(name)
|
||||||
if h := pluginEntryHash(plgDir); h != "" {
|
if h := pluginEntryHash(plgDir); h != "" {
|
||||||
r.pluginHashes[name] = h
|
r.pluginHashes[name] = h
|
||||||
} else {
|
} else {
|
||||||
@ -556,7 +584,50 @@ func (r *Registry) stageRegistrarFor() (func(plugin string, stage sdk.Stage, han
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// warnOutputOnlyChannels 在插件 Start 结束后,报告"只声明了出站、没有入站声明"的通道。
|
||||||
|
//
|
||||||
|
// 为什么放在 Start 之后:声明顺序自由(先出站后入站很常见),注册时刻的状态
|
||||||
|
// 判不出意图。这里看的是**插件最终声明了什么**,因此不会误报 qq 这种写法。
|
||||||
|
//
|
||||||
|
// 注:这类通道内核已兜底登记 inputch(功能可用),告警只是提醒插件作者把意图写明。
|
||||||
|
func (r *Registry) warnOutputOnlyChannels(plugin string) {
|
||||||
|
r.channelsMu.Lock()
|
||||||
|
set := r.pluginChannels[plugin]
|
||||||
|
var only []string
|
||||||
|
if set != nil {
|
||||||
|
for ch := range set.outputs {
|
||||||
|
if !set.inputs[ch] {
|
||||||
|
only = append(only, ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.channelsMu.Unlock()
|
||||||
|
sort.Strings(only)
|
||||||
|
for _, ch := range only {
|
||||||
|
log.Printf("[plugin] %s 只声明了出站通道 %q(未 RegisterInputChannel);"+
|
||||||
|
"内核已兜底登记 inputch,若这是有意为之可忽略", plugin, ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// noteChannel 记住插件注册了哪个通道,供卸载/崩溃时摘除。
|
// noteChannel 记住插件注册了哪个通道,供卸载/崩溃时摘除。
|
||||||
|
// forgetChannel 把某个通道从"本插件注册过哪些通道"的记账里摘掉(注销通道时用)。
|
||||||
|
//
|
||||||
|
// 不摘的话 status 的 channels 列表与 input_channels 视图会一直列着已死通道,
|
||||||
|
// 模型会以为它还在(远程设备掉线后尤其明显)。
|
||||||
|
func (r *Registry) forgetChannel(plugin, channel string) {
|
||||||
|
if plugin == "" || channel == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.channelsMu.Lock()
|
||||||
|
defer r.channelsMu.Unlock()
|
||||||
|
set := r.pluginChannels[plugin]
|
||||||
|
if set == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(set.outputs, channel)
|
||||||
|
delete(set.inputs, channel)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Registry) noteChannel(plugin, channel string, output bool) {
|
func (r *Registry) noteChannel(plugin, channel string, output bool) {
|
||||||
if plugin == "" || channel == "" {
|
if plugin == "" || channel == "" {
|
||||||
return
|
return
|
||||||
|
|||||||
@ -221,6 +221,8 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
p.notifyInterval = DefaultNotifyInterval
|
p.notifyInterval = DefaultNotifyInterval
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// agentcli 通道:终端生命周期/输出事件经它注入 agent(见本文件 InjectText* 调用)。
|
||||||
|
_ = s.RegisterInputChannel("agentcli", sdk.ChannelDef{})
|
||||||
s.RegisterTool("terminal_create", sdk.ToolDef{
|
s.RegisterTool("terminal_create", sdk.ToolDef{
|
||||||
Name: "terminal_create",
|
Name: "terminal_create",
|
||||||
Description: "创建一个新的交互式终端会话。返回终端 ID,后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。" +
|
Description: "创建一个新的交互式终端会话。返回终端 ID,后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。" +
|
||||||
|
|||||||
@ -192,6 +192,9 @@ func (r *ChannelRegistry) Dispatch(data json.RawMessage, pluginName string, sp *
|
|||||||
caps = 1
|
caps = 1
|
||||||
}
|
}
|
||||||
desc := fmt.Sprintf("OC channel %s (from %s)", chName, pn)
|
desc := fmt.Sprintf("OC channel %s (from %s)", chName, pn)
|
||||||
|
// 通道名来自外部 OC 配置(动态):声明处**同时**登记 inputch ——
|
||||||
|
// 该通道既收(InjectInputSync 见 plugin.go)又发(output_send__<通道>)。
|
||||||
|
_ = s.RegisterInputChannel(chName, sdk.ChannelDef{})
|
||||||
s.RegisterOutputChannel(chName, caps, desc, sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
|
s.RegisterOutputChannel(chName, caps, desc, sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
return sp.CallTool(chName, args)
|
return sp.CallTool(chName, args)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -59,6 +59,9 @@ func (p *Plugin) Name() string { return p.name }
|
|||||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
s.SetAutoRestart(true)
|
s.SetAutoRestart(true)
|
||||||
|
|
||||||
|
// inputch 先登记:本插件既用 "cli" 作输出目标,也用它注入输入(终端行)。
|
||||||
|
// 输入侧必须显式登记,否则"把 inputch 划给驻留子"会找不到它。
|
||||||
|
_ = s.RegisterInputChannel("cli", sdk.ChannelDef{})
|
||||||
s.RegisterOutputChannel("cli", 1, "CLI 终端", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
|
s.RegisterOutputChannel("cli", 1, "CLI 终端", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
payload, _ := args["payload"].(string)
|
payload, _ := args["payload"].(string)
|
||||||
if payload != "" {
|
if payload != "" {
|
||||||
|
|||||||
196
internal/plugins/deepsearch_e2e_test.go
Normal file
196
internal/plugins/deepsearch_e2e_test.go
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
//go:build linux || darwin
|
||||||
|
|
||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// forbidStoppingSharedBackend 给测试实例关上「退出时停后端」这道闸。
|
||||||
|
//
|
||||||
|
// 本机 127.0.0.1:8888 的 SearXNG 是**线上服务在用的**。插件侧已保证「只关自己拉起的实例」,
|
||||||
|
// 这里再加一道:即便这个测试实例真的把它拉起来了,退出时也不关 —— 否则跑一次测试就断一次
|
||||||
|
// 线上搜索(实测被这条坑过:E2E 临时目录里的插件实例在 teardown 时 `docker compose stop`,
|
||||||
|
// 表现为「搜索后端起不来」)。
|
||||||
|
func forbidStoppingSharedBackend(t *testing.T, env *testPluginEnv) {
|
||||||
|
t.Helper()
|
||||||
|
if env.cfgReg == nil {
|
||||||
|
t.Fatal("测试环境没有配置注册表,无法关闭后端托管")
|
||||||
|
}
|
||||||
|
ps := env.cfgReg.PluginConfig("deepsearch")
|
||||||
|
// 配置表只在 RegisterDef 时创建(内核有意为之:读写不隐式建表)。
|
||||||
|
// 插件启动时才会注册自己的 def,而我们要在那之前写入,所以这里先注册这个键。
|
||||||
|
ps.RegisterDef(internalConfig.ConfigDef{Key: "stop_searxng_on_exit", Type: "bool", Default: "true"})
|
||||||
|
if err := ps.Set("stop_searxng_on_exit", "false"); err != nil {
|
||||||
|
t.Fatalf("写 stop_searxng_on_exit=false 失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deepsearch 插件的真实调用往返:内核 ExecuteTool → RPC → 插件子进程 → 本地 SearXNG → 结果回传。
|
||||||
|
//
|
||||||
|
// 与 TestRealPlugin_ToolInvokeRoundTrip 的区别:那条只断言「链路通(拿到结果或拿到插件侧的错误)」,
|
||||||
|
// 这条断言**内容形状**——返回里必须有「摘要」与「引擎覆盖度」。这正是旧实现(抓 Bing HTML)
|
||||||
|
// 拿不到的东西,也是「搜索能力不行」的根因,所以它必须成为回归判据。
|
||||||
|
//
|
||||||
|
// 前置:本机 127.0.0.1:8888 上跑着 SearXNG(部署见 /root/searxng-agent)。
|
||||||
|
// 未启动时 fail 并给出可操作提示,而不是 skip —— 否则这条判据会在环境退化时静默失效。
|
||||||
|
func TestRealPlugin_DeepSearchInvoke(t *testing.T) {
|
||||||
|
env := setupIntegration(t)
|
||||||
|
defer env.cleanup()
|
||||||
|
|
||||||
|
plgDir := filepath.Join(env.tmpDir, "plugins")
|
||||||
|
installRealPlugin(t, plgDir, "deepsearch")
|
||||||
|
forbidStoppingSharedBackend(t, env)
|
||||||
|
|
||||||
|
if err := env.pluginReg.Load(plgDir); err != nil {
|
||||||
|
t.Fatalf("加载插件: %v", err)
|
||||||
|
}
|
||||||
|
if env.pluginReg.Get("deepsearch") == nil {
|
||||||
|
t.Fatal("deepsearch 未经 proc 通道加载")
|
||||||
|
}
|
||||||
|
|
||||||
|
var toolName string
|
||||||
|
for _, def := range env.stageHost.GetToolDefs() {
|
||||||
|
if strings.HasPrefix(def.Name, "deepsearch") && strings.HasSuffix(def.Name, "_search") {
|
||||||
|
toolName = def.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if toolName == "" {
|
||||||
|
t.Fatal("deepsearch 未注册检索工具")
|
||||||
|
}
|
||||||
|
t.Logf("调用工具 %s", toolName)
|
||||||
|
|
||||||
|
// 用当初失败的那条查询做判据
|
||||||
|
res, err := env.stageHost.ExecuteTool(toolName, map[string]interface{}{
|
||||||
|
"query": "深度科技 deepin 开发者 被开除",
|
||||||
|
"count": float64(3),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "not found in any plugin") {
|
||||||
|
t.Fatalf("工具未注册到 stageHost: %v", err)
|
||||||
|
}
|
||||||
|
t.Fatalf("工具调用失败(检查本机 SearXNG 是否在 127.0.0.1:8888 运行): %v", err)
|
||||||
|
}
|
||||||
|
if res == nil {
|
||||||
|
t.Fatal("工具返回 nil 且无错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
text := fmt.Sprintf("%v", res)
|
||||||
|
t.Logf("工具返回前 500 字:\n%s", truncRunes(text, 500))
|
||||||
|
|
||||||
|
if !strings.Contains(text, "摘要:") {
|
||||||
|
t.Errorf("返回内容缺少摘要——这正是旧实现拿不到的部分:\n%s", truncRunes(text, 800))
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "覆盖:") {
|
||||||
|
t.Errorf("返回内容缺少引擎覆盖度(模型据此判断可信度):\n%s", truncRunes(text, 800))
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "http") {
|
||||||
|
t.Errorf("返回内容缺少结果链接:\n%s", truncRunes(text, 800))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deepsearch_status 也走一遍真实调用:它把「后端是否可用、哪些引擎在出结果」暴露成工具,
|
||||||
|
// 出问题时 agent 可以先自检,而不是盲目换词重搜。
|
||||||
|
func TestRealPlugin_DeepSearchStatusInvoke(t *testing.T) {
|
||||||
|
env := setupIntegration(t)
|
||||||
|
defer env.cleanup()
|
||||||
|
|
||||||
|
plgDir := filepath.Join(env.tmpDir, "plugins")
|
||||||
|
installRealPlugin(t, plgDir, "deepsearch")
|
||||||
|
forbidStoppingSharedBackend(t, env)
|
||||||
|
if err := env.pluginReg.Load(plgDir); err != nil {
|
||||||
|
t.Fatalf("加载插件: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var toolName string
|
||||||
|
for _, def := range env.stageHost.GetToolDefs() {
|
||||||
|
if strings.HasPrefix(def.Name, "deepsearch") && strings.HasSuffix(def.Name, "_status") {
|
||||||
|
toolName = def.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if toolName == "" {
|
||||||
|
t.Fatal("deepsearch 未注册自检工具")
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := env.stageHost.ExecuteTool(toolName, map[string]interface{}{"probe": "test"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("自检调用失败(检查本机 SearXNG 是否运行): %v", err)
|
||||||
|
}
|
||||||
|
text := fmt.Sprintf("%v", res)
|
||||||
|
t.Logf("自检返回:%s", truncRunes(text, 400))
|
||||||
|
|
||||||
|
for _, want := range []string{"healthz", "search_ok", "engines_returning_results"} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Errorf("自检结果缺少字段 %q:%s", want, truncRunes(text, 500))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncRunes(s string, n int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(r[:n]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插件的停止**不得带走共享后端**:这是「搜索后端起不来」的直接回归判据。
|
||||||
|
// 判据落在网络层(healthz 仍然 200),而不是「有没有执行 docker 命令」。
|
||||||
|
func TestRealPlugin_DeepSearchKeepsSharedBackendOnStop(t *testing.T) {
|
||||||
|
env := setupIntegration(t)
|
||||||
|
defer env.cleanup()
|
||||||
|
|
||||||
|
requireSearxngUp(t)
|
||||||
|
|
||||||
|
plgDir := filepath.Join(env.tmpDir, "plugins")
|
||||||
|
installRealPlugin(t, plgDir, "deepsearch")
|
||||||
|
forbidStoppingSharedBackend(t, env)
|
||||||
|
if err := env.pluginReg.Load(plgDir); err != nil {
|
||||||
|
t.Fatalf("加载插件: %v", err)
|
||||||
|
}
|
||||||
|
if env.pluginReg.Get("deepsearch") == nil {
|
||||||
|
t.Fatal("deepsearch 未加载")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停掉这个插件实例(相当于测试 teardown / 另一个 daemon 退出)
|
||||||
|
if err := env.pluginReg.StopAndUnload("deepsearch"); err != nil {
|
||||||
|
t.Fatalf("停止插件失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 给「如果它真去关了」留出执行窗口:stop -t 2 最多 2 秒
|
||||||
|
deadline := time.Now().Add(6 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if !searxngHealthy() {
|
||||||
|
t.Fatalf("插件停止把共享后端带走了(healthz 不再是 200)—— 线上搜索会因此不可用")
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Log("插件已停止,共享后端仍在服务")
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireSearxngUp 前置检查:后端不在时 fail 并给出可操作提示(不 skip,避免环境退化时静默失效)
|
||||||
|
func requireSearxngUp(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if !searxngHealthy() {
|
||||||
|
t.Fatal("本机 127.0.0.1:8888 的 SearXNG 不可用;先 `cd /root/searxng-agent && docker compose up -d`")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func searxngHealthy() bool {
|
||||||
|
cl := &http.Client{Timeout: 3 * time.Second}
|
||||||
|
resp, err := cl.Get("http://127.0.0.1:8888/healthz")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return resp.StatusCode == http.StatusOK
|
||||||
|
}
|
||||||
@ -28,6 +28,7 @@ type testPluginEnv struct {
|
|||||||
memDB *memory.GraphDB
|
memDB *memory.GraphDB
|
||||||
ks *knowledge.Store
|
ks *knowledge.Store
|
||||||
docStore *doc.Store
|
docStore *doc.Store
|
||||||
|
cfgReg *internalConfig.ConfigRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupIntegration(t *testing.T) *testPluginEnv {
|
func setupIntegration(t *testing.T) *testPluginEnv {
|
||||||
@ -101,6 +102,7 @@ func setupIntegrationWithProvider(t *testing.T, pm *agentAPI.ProviderManager) *t
|
|||||||
memDB: memDB,
|
memDB: memDB,
|
||||||
ks: ks,
|
ks: ks,
|
||||||
docStore: docStore,
|
docStore: docStore,
|
||||||
|
cfgReg: cfgReg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -182,6 +182,9 @@ func (d *devicectlDevice) Tools() []agentIO.ToolDef {
|
|||||||
|
|
||||||
func (d *devicectlDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
func (d *devicectlDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
||||||
switch tool {
|
switch tool {
|
||||||
|
case "output":
|
||||||
|
// 出站:见 outputch.go。声明了 CapStructured 就必须真的能收输出。
|
||||||
|
return d.output(args)
|
||||||
case "devicedetect":
|
case "devicedetect":
|
||||||
return d.detect(args)
|
return d.detect(args)
|
||||||
case "device_ctl_status":
|
case "device_ctl_status":
|
||||||
|
|||||||
318
internal/plugins/remotedevice/outputch.go
Normal file
318
internal/plugins/remotedevice/outputch.go
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
package remotedevice
|
||||||
|
|
||||||
|
// 设备输出通道:把"agent 主动发给设备"做成**每设备一个输出通道** `device-<id>`。
|
||||||
|
//
|
||||||
|
// 为什么是输出通道而不是再加一批工具:
|
||||||
|
// - **寻址**:`output_send__device-<id>` 直接指名道姓;模型看 `output_list_channels`
|
||||||
|
// 就知道当前有哪些设备在线,不必先 `devicedetect` 再往参数里塞 device_id。
|
||||||
|
// - **能力**:caps 由设备声明的 caps 映射,**内核**在发送前就按 caps 拦
|
||||||
|
// (把图片发给只支持文本的音箱会被拒,而不是等设备侧报错)。
|
||||||
|
// - **授权**:`AllowedOutputs` 是内核级的授权闸(`executeOutputSendTool` 里先查
|
||||||
|
// `IsOutputAllowed`)。父 agent 因此可以"只授权某一台设备"给驻留子 ——
|
||||||
|
// 这在工具模型下做不到(拿到 `device_ctl_cmdrun` 就能对任意设备下指令)。
|
||||||
|
//
|
||||||
|
// 而 `screensee`/`computeruse`/`device_ctl_*` 这类**请求-响应**仍留作工具:
|
||||||
|
// 它们的返回值(图像/命令输出/状态)必须进模型上下文,做成通道会丢掉这个语义。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deviceOutputCaps 把设备声明的 caps 映射成输出通道能力位。
|
||||||
|
//
|
||||||
|
// 映射依据(与 registry.go 的 capabilityTools/compatFullCaps 同一套词表):
|
||||||
|
// - 文本:任何设备都收(消息/指令都是文本)→ 总是 CapText
|
||||||
|
// - 结构化:能跑命令(cmd/cmdrun/cmdresult 视为历史全能力)→ 能渲染结构化结果
|
||||||
|
// - 音频:speaker/speakeruse,或 kind=speaker
|
||||||
|
// - 图片/文件:有屏(screen/screensue/screensee/gui)、剪切板、摄像头,或 kind 是带屏设备
|
||||||
|
// - **未声明任何已知能力** → 视为全能力(与 deviceSupportsTool 的旧设备兼容规则一致)
|
||||||
|
func deviceOutputCaps(caps []string, kind string) agentIO.OutputCapability {
|
||||||
|
full := agentIO.CapText | agentIO.CapFile | agentIO.CapImage | agentIO.CapAudio | agentIO.CapStructured
|
||||||
|
|
||||||
|
out := agentIO.CapText
|
||||||
|
known := false
|
||||||
|
for _, c := range caps {
|
||||||
|
switch c {
|
||||||
|
case "cmd", "cmdrun", "cmdresult":
|
||||||
|
// 历史"全能力"标记:这类设备能跑命令、能收结构化结果。
|
||||||
|
return full
|
||||||
|
case "screen", "screensue", "screensee", "gui", "display":
|
||||||
|
known = true
|
||||||
|
out |= agentIO.CapImage | agentIO.CapFile
|
||||||
|
case "clipboard", "clipboardsee", "clipboardsue":
|
||||||
|
known = true
|
||||||
|
out |= agentIO.CapFile
|
||||||
|
case "camera", "camerasue":
|
||||||
|
known = true
|
||||||
|
out |= agentIO.CapImage | agentIO.CapFile
|
||||||
|
case "speaker", "speakeruse", "audio":
|
||||||
|
known = true
|
||||||
|
out |= agentIO.CapAudio
|
||||||
|
case "computeruse":
|
||||||
|
known = true
|
||||||
|
out |= agentIO.CapStructured
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// kind 兜底:带屏设备即便没声明 caps,也能收图和文件。
|
||||||
|
switch kind {
|
||||||
|
case "computer", "phone", "tablet", "tv":
|
||||||
|
known = true
|
||||||
|
out |= agentIO.CapImage | agentIO.CapFile | agentIO.CapStructured
|
||||||
|
case "speaker":
|
||||||
|
known = true
|
||||||
|
out |= agentIO.CapAudio
|
||||||
|
}
|
||||||
|
if !known {
|
||||||
|
return full // 旧设备兼容:未声明已知能力 ⇒ 全能力
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceChannelName 由**设备自报的 id** 派生一个合规且唯一的通道名:`device-<派生值>`。
|
||||||
|
//
|
||||||
|
// 入站与出站**同名**:两者指的是同一台设备,分成两个名字只会让模型与授权表更难对。
|
||||||
|
//
|
||||||
|
// 为什么不能直接用 id:通道名会被内核拼进 LLM 的**函数名**(`output_send__<通道名>`),
|
||||||
|
// 上游规范是 `^[a-zA-Z0-9_-]{1,64}$`;而设备 id 是**外部输入**(设备自己声明),
|
||||||
|
// 可能含空格/非 ASCII/超长。违规的后果不是"这个工具不能用",而是**整条请求被 400 拒绝** ——
|
||||||
|
// 实测把生产打挂:`Invalid 'tools[299].function.name'`,网关 auto tier 全链条失败,
|
||||||
|
// 内核只能报"所有 provider 都失败",表现成"整个 agent 不说话了"。
|
||||||
|
//
|
||||||
|
// 派生规则(确定性,同一 id 永远同名):
|
||||||
|
// 1. 保留 [A-Za-z0-9_-],其它字符折成 '-';折叠后为空则用 "dev"
|
||||||
|
// 2. 截断到 maxDeviceChannelSuffix 字符(给 "device-" 与短哈希留余量)
|
||||||
|
// 3. 若发生截断,或该名字已被**另一个** id 占用,则追加 id 的 6 位短哈希
|
||||||
|
//
|
||||||
|
// 设备 id 本身仍用于路由与日志(真名不丢),通道名只是它派生的标识符。
|
||||||
|
func (p *Plugin) deviceChannelName(id string) string {
|
||||||
|
p.devChansMu.Lock()
|
||||||
|
defer p.devChansMu.Unlock()
|
||||||
|
if p.devChans == nil {
|
||||||
|
p.devChans = make(map[string]string)
|
||||||
|
}
|
||||||
|
if name, ok := p.devChans[id]; ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range id {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
|
||||||
|
b.WriteRune(r)
|
||||||
|
default:
|
||||||
|
b.WriteByte('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base := b.String()
|
||||||
|
if base == "" {
|
||||||
|
base = "dev"
|
||||||
|
}
|
||||||
|
truncated := false
|
||||||
|
if len(base) > maxDeviceChannelSuffix {
|
||||||
|
base = base[:maxDeviceChannelSuffix]
|
||||||
|
truncated = true
|
||||||
|
}
|
||||||
|
name := "device-" + base
|
||||||
|
// 撞名检查:不同 id 折出同一个名字时必须可区分
|
||||||
|
for otherID, otherName := range p.devChans {
|
||||||
|
if otherName == name && otherID != id {
|
||||||
|
truncated = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if truncated {
|
||||||
|
sum := sha1.Sum([]byte(id))
|
||||||
|
name += "-" + hex.EncodeToString(sum[:3])
|
||||||
|
}
|
||||||
|
p.devChans[id] = name
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// maxDeviceChannelSuffix 是通道名主体的长度上限。
|
||||||
|
// 预算:上游函数名上限 64 = "output_send__"(13) + "device-"(7) + 主体 + "-"+短哈希(7)
|
||||||
|
// ⇒ 主体最多 37;取 32 留余量(改名/前缀变动不会立刻越界)。
|
||||||
|
maxDeviceChannelSuffix = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
// wireDeviceChannels 把"设备上下线"接到通道的登记/注销上。
|
||||||
|
//
|
||||||
|
// 一台设备 = 一对**同名**通道 `device-<id>`:入站(设备上报 → agent)与出站
|
||||||
|
// (agent → 设备)。用**同步回调**而不是 ChangeChan(后者是 select+default,
|
||||||
|
// 缓冲满会丢事件;丢一次就留下死通道或漏注册)。
|
||||||
|
//
|
||||||
|
// 抽成方法而不是内联在 Start 里:测试要能走**同一条**接线,
|
||||||
|
// 否则测试自己塞 handler,Start 忘了接线也照样绿。
|
||||||
|
func (p *Plugin) wireDeviceChannels() {
|
||||||
|
p.registry.SetPresenceHandler(
|
||||||
|
func(meta DeviceMeta) {
|
||||||
|
_ = p.sdk.RegisterInputChannel(p.deviceChannelName(meta.DeviceID), sdk.ChannelDef{})
|
||||||
|
p.ensureDeviceOutputChannel(meta.DeviceID)
|
||||||
|
},
|
||||||
|
func(id string) { p.dropDeviceOutputChannel(id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureDeviceOutputChannel 给在线设备注册输出通道 device-<id>(幂等)。
|
||||||
|
func (p *Plugin) ensureDeviceOutputChannel(id string) {
|
||||||
|
if p.sdk == nil || id == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meta, ok := p.registry.Get(id)
|
||||||
|
if !ok || !meta.Online {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch := p.deviceChannelName(id)
|
||||||
|
caps := deviceOutputCaps(meta.Caps, meta.Kind)
|
||||||
|
desc := fmt.Sprintf("远程设备 %s(%s):agent 主动向该设备发送内容;能力位 %s",
|
||||||
|
id, fallback(meta.Name, meta.Kind), agentIO.OutputCapability(caps).String())
|
||||||
|
// 重复注册是安全的:芯片侧 Register 会合并(owner/capacity 取旧值)。
|
||||||
|
if err := p.sdk.RegisterOutputChannel(ch, int(caps), desc, sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
return pushToDevice(p.registry, id, args)
|
||||||
|
}); err != nil {
|
||||||
|
p.logf("register output channel %s: %v", ch, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.logf("device %s online → 输出通道 %s(caps=%s)", id, ch, agentIO.OutputCapability(caps).String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropDeviceOutputChannel 设备下线时注销它的输出通道。
|
||||||
|
//
|
||||||
|
// 不注销的后果:`output_list_channels` 一直列着它,模型会往死通道发消息,
|
||||||
|
// 拿到的却只是"发送已提交"之类的假回执。
|
||||||
|
func (p *Plugin) dropDeviceOutputChannel(id string) {
|
||||||
|
if p.sdk == nil || id == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch := p.deviceChannelName(id)
|
||||||
|
if err := p.sdk.UnregisterOutputChannel(ch); err != nil {
|
||||||
|
p.logf("unregister output channel %s: %v", ch, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.logf("device %s offline → 注销输出通道 %s", id, ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushToDevice 把一次 output_send 的 {payload,type,meta} 转成下行帧发给设备。
|
||||||
|
//
|
||||||
|
// 线上格式(新增 op=push,与既有 op=cmd/cmd_speech_* 并列):
|
||||||
|
//
|
||||||
|
// {"op":"push","req_id":"...","type":"text|image|file|audio|structured","payload":"...","meta":"..."}
|
||||||
|
//
|
||||||
|
// 大负载(data URL 形式的图片/音频/文件)走既有分块通道 PushData,
|
||||||
|
// 避免把 base64 塞进一个超大文本帧。
|
||||||
|
func pushToDevice(reg *Registry, id string, args map[string]interface{}) (interface{}, error) {
|
||||||
|
payload, _ := args["payload"].(string)
|
||||||
|
typ, _ := args["type"].(string)
|
||||||
|
metaStr, _ := args["meta"].(string)
|
||||||
|
if payload == "" {
|
||||||
|
return nil, fmt.Errorf("payload 不能为空")
|
||||||
|
}
|
||||||
|
if typ == "" {
|
||||||
|
typ = "text"
|
||||||
|
}
|
||||||
|
reqID := fmt.Sprintf("push_%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
if data, mime, ok := decodeDataURL(payload); ok && typ != "text" && typ != "structured" {
|
||||||
|
if err := reg.PushData(id, reqID, typ, mime, data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"status": "sent", "req_id": reqID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
frame := map[string]interface{}{
|
||||||
|
"op": "push",
|
||||||
|
"req_id": reqID,
|
||||||
|
"type": typ,
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
if metaStr != "" {
|
||||||
|
frame["meta"] = metaStr
|
||||||
|
}
|
||||||
|
if err := reg.PushJSON(id, frame); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"status": "sent", "req_id": reqID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeDataURL 解析 data:<mime>;base64,<data> 形式的内联负载。
|
||||||
|
func decodeDataURL(s string) (data []byte, mime string, ok bool) {
|
||||||
|
if !strings.HasPrefix(s, "data:") {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
rest := strings.TrimPrefix(s, "data:")
|
||||||
|
comma := strings.Index(rest, ",")
|
||||||
|
if comma < 0 {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
head, body := rest[:comma], rest[comma+1:]
|
||||||
|
if !strings.HasSuffix(head, ";base64") {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
mime = strings.TrimSuffix(head, ";base64")
|
||||||
|
b, err := base64.StdEncoding.DecodeString(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
return b, mime, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallback(s, def string) string {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) logf(format string, a ...interface{}) {
|
||||||
|
log.Printf("[remotedevice] "+format, a...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// output 是**聚合通道** `devicectl` 的出站实现(每设备通道之外的另一条路)。
|
||||||
|
//
|
||||||
|
// 历史状态:devicectlDevice 一直声明 OutputCapabilities=CapStructured,
|
||||||
|
// 但 Execute 里根本没有 "output" 分支 ⇒ `output_send__devicectl` 必然报
|
||||||
|
// "unknown device tool output"。这里把它补实:按 meta/device_id 指到具体设备。
|
||||||
|
//
|
||||||
|
// 寻址方式(两者都收,模型的写法越少歧义越好):
|
||||||
|
// - args.meta 是 JSON 且含 device_id:{"device_id":"phone-1"}
|
||||||
|
// - args.meta 直接就是设备 id:phone-1
|
||||||
|
// - args.device_id
|
||||||
|
//
|
||||||
|
// 留空则返回**可执行**的提示(列出在线设备),而不是含糊报错 —— 模型据此重试。
|
||||||
|
func (d *devicectlDevice) output(args map[string]interface{}) (interface{}, error) {
|
||||||
|
deviceID, _ := args["device_id"].(string)
|
||||||
|
if deviceID == "" {
|
||||||
|
if metaStr, _ := args["meta"].(string); metaStr != "" {
|
||||||
|
var m map[string]interface{}
|
||||||
|
if json.Unmarshal([]byte(metaStr), &m) == nil {
|
||||||
|
deviceID, _ = m["device_id"].(string)
|
||||||
|
if deviceID == "" {
|
||||||
|
deviceID, _ = m["device"].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if deviceID == "" {
|
||||||
|
deviceID = strings.TrimSpace(metaStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if deviceID == "" {
|
||||||
|
ids := []string{}
|
||||||
|
for _, m := range d.reg.OnlineList() {
|
||||||
|
ids = append(ids, m.DeviceID)
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil, fmt.Errorf("devicectl 需要 meta.device_id 才能投递;当前没有在线设备(device_list_channels 可看每台设备的 device-<id> 通道)")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("devicectl 需要 meta.device_id(或直接用通道 device-<id>);当前在线设备: %s", strings.Join(ids, ", "))
|
||||||
|
}
|
||||||
|
return pushToDevice(d.reg, deviceID, args)
|
||||||
|
}
|
||||||
287
internal/plugins/remotedevice/outputch_test.go
Normal file
287
internal/plugins/remotedevice/outputch_test.go
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
package remotedevice
|
||||||
|
|
||||||
|
// 设备输出通道(outputch.go)的测试:
|
||||||
|
// - caps 映射词表
|
||||||
|
// - 上下线 → 通道登记/注销 + push 真能落到设备(走真 WS 帧)
|
||||||
|
// - 聚合通道 devicectl 的出站寻址
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeviceOutputCapsMapping(t *testing.T) {
|
||||||
|
full := agentIO.CapText | agentIO.CapFile | agentIO.CapImage | agentIO.CapAudio | agentIO.CapStructured
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
caps []string
|
||||||
|
kind string
|
||||||
|
want agentIO.OutputCapability
|
||||||
|
}{
|
||||||
|
{"未声明已知能力 ⇒ 全能力(旧设备兼容)", nil, "", full},
|
||||||
|
{"未知 caps ⇒ 全能力", []string{"whatever"}, "", full},
|
||||||
|
{"cmd ⇒ 历史全能力", []string{"cmd"}, "", full},
|
||||||
|
{"speaker ⇒ 文本+音频", []string{"speaker"}, "", agentIO.CapText | agentIO.CapAudio},
|
||||||
|
{"screen ⇒ 文本+图+文件", []string{"screen"}, "", agentIO.CapText | agentIO.CapImage | agentIO.CapFile},
|
||||||
|
{"clipboard ⇒ 文本+文件", []string{"clipboard"}, "", agentIO.CapText | agentIO.CapFile},
|
||||||
|
{"kind=computer 兜底(未声明 caps)", nil, "computer", agentIO.CapText | agentIO.CapImage | agentIO.CapFile | agentIO.CapStructured},
|
||||||
|
{"kind=speaker 兜底", nil, "speaker", agentIO.CapText | agentIO.CapAudio},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := deviceOutputCaps(c.caps, c.kind); got != c.want {
|
||||||
|
t.Errorf("%s: deviceOutputCaps(%v,%q)=%s,期望 %s", c.name, c.caps, c.kind, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// channelRecorder 记录通道注册/注销。
|
||||||
|
//
|
||||||
|
// 必须加锁:注册/注销发生在设备 WS 的处理 goroutine(上下线回调)里,
|
||||||
|
// 而测试在主线读 —— 裸 map/slice 会被 -race 抓住(第一版就是这么被抓住的)。
|
||||||
|
type channelRecorder struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
registered map[string]int
|
||||||
|
unregistered []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *channelRecorder) caps(name string) (int, bool) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
c, ok := r.registered[name]
|
||||||
|
return c, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *channelRecorder) unregList() []string {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return append([]string(nil), r.unregistered...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordingSDK 造一个能记账的 PluginSDK:通道注册/注销都落到 recorder 里。
|
||||||
|
func recordingSDK(t *testing.T, iom *agentIO.IOManager, rec *channelRecorder) *sdk.PluginSDK {
|
||||||
|
t.Helper()
|
||||||
|
return sdk.New("remotedevice", sdk.SDKConfig{
|
||||||
|
IOManager: iom,
|
||||||
|
RegOutput: func(name string, caps int, desc string, def sdk.ChannelDef, handler sdk.ToolHandler) error {
|
||||||
|
rec.mu.Lock()
|
||||||
|
rec.registered[name] = caps
|
||||||
|
rec.mu.Unlock()
|
||||||
|
// 通道 handler 也要真的可调用 —— 记进 iom 才能从外面触发。
|
||||||
|
return iom.RegisterDevice(&recordingDevice{name: name, handler: handler})
|
||||||
|
},
|
||||||
|
RegOutputUnreg: func(name string) error {
|
||||||
|
rec.mu.Lock()
|
||||||
|
rec.unregistered = append(rec.unregistered, name)
|
||||||
|
rec.mu.Unlock()
|
||||||
|
iom.UnregisterDevice(name)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
RegInput: func(name string, def sdk.ChannelDef) error {
|
||||||
|
iom.RegisterInputChannel(name, agentIO.ChannelDef(def))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordingDevice 把插件注册的输出通道在 io 层落地,便于用 Execute("output") 触发。
|
||||||
|
type recordingDevice struct {
|
||||||
|
name string
|
||||||
|
handler sdk.ToolHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *recordingDevice) Name() string { return d.name }
|
||||||
|
func (d *recordingDevice) Type() agentIO.DeviceType { return agentIO.DeviceIO }
|
||||||
|
func (d *recordingDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText }
|
||||||
|
func (d *recordingDevice) Description() string { return "recording device" }
|
||||||
|
func (d *recordingDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
|
||||||
|
func (d *recordingDevice) Start() error { return nil }
|
||||||
|
func (d *recordingDevice) Stop() error { return nil }
|
||||||
|
func (d *recordingDevice) Tools() []agentIO.ToolDef { return nil }
|
||||||
|
func (d *recordingDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
||||||
|
return d.handler(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceChannelLifecycleAndPush(t *testing.T) {
|
||||||
|
reg := NewRegistry()
|
||||||
|
token := "tk"
|
||||||
|
reg.SetAcceptToken(func(p string) bool { return p == token })
|
||||||
|
|
||||||
|
iom := agentIO.NewIOManager()
|
||||||
|
rec := &channelRecorder{registered: map[string]int{}}
|
||||||
|
|
||||||
|
p := &Plugin{registry: reg}
|
||||||
|
p.sdk = recordingSDK(t, iom, rec)
|
||||||
|
p.wireDeviceChannels() // 走 Start 的同一条接线
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cli := dialTestWS(t, srv.URL, token)
|
||||||
|
defer cli.close()
|
||||||
|
|
||||||
|
// 设备上线(caps=speaker ⇒ 通道能力应为 文本+音频)
|
||||||
|
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"spk-1","name":"音箱","kind":"speaker","caps":["speaker"]}}`))
|
||||||
|
cli.readHelloAckAndBind(t, token)
|
||||||
|
|
||||||
|
ch := p.deviceChannelName("spk-1")
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
caps, ok := rec.caps(ch)
|
||||||
|
for !ok && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
caps, ok = rec.caps(ch)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("设备上线后应注册输出通道 %s,实际: %v", ch, rec.unregList())
|
||||||
|
}
|
||||||
|
if want := int(agentIO.CapText | agentIO.CapAudio); caps != want {
|
||||||
|
t.Fatalf("通道能力位应为 文本+音频(%d),实际 %d", want, caps)
|
||||||
|
}
|
||||||
|
// 入站 inputch 同名登记(父 agent 才能把"这台设备"划给驻留子)
|
||||||
|
if _, ok := iom.LookupInputChannel(ch); !ok {
|
||||||
|
t.Fatalf("设备上线后应同时登记同名 inputch %s", ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发一次出站:走 io 层的 output 分发(与 output_send__<通道> 同一条路)
|
||||||
|
dev := iom.GetDevice(ch)
|
||||||
|
if dev == nil {
|
||||||
|
t.Fatalf("输出通道 %s 未在 io 层注册", ch)
|
||||||
|
}
|
||||||
|
if _, err := dev.Execute("output", map[string]interface{}{"payload": "你好,设备", "type": "text"}); err != nil {
|
||||||
|
t.Fatalf("向设备发送失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设备侧应收到 op=push 的帧
|
||||||
|
got := make(chan map[string]interface{}, 1)
|
||||||
|
go func() {
|
||||||
|
_, payload, err := cli.readMsg()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var m map[string]interface{}
|
||||||
|
if json.Unmarshal(payload, &m) == nil {
|
||||||
|
got <- m
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case m := <-got:
|
||||||
|
if m["op"] != "push" {
|
||||||
|
t.Fatalf("设备应收到 op=push,实际 %v", m)
|
||||||
|
}
|
||||||
|
if m["payload"] != "你好,设备" || m["type"] != "text" {
|
||||||
|
t.Fatalf("push 帧内容不符: %v", m)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("设备未收到 push 帧")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设备下线 ⇒ 注销通道(不留死通道)
|
||||||
|
cli.close()
|
||||||
|
deadline = time.Now().Add(3 * time.Second)
|
||||||
|
for len(rec.unregList()) == 0 && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if got := rec.unregList(); len(got) == 0 || got[0] != ch {
|
||||||
|
t.Fatalf("设备下线后应注销 %s,实际 %v", ch, got)
|
||||||
|
}
|
||||||
|
if iom.GetDevice(ch) != nil {
|
||||||
|
t.Fatalf("注销后 io 层不应还有 %s", ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevicectlAggregateOutputAddressing(t *testing.T) {
|
||||||
|
reg := NewRegistry()
|
||||||
|
token := "tk2"
|
||||||
|
reg.SetAcceptToken(func(p string) bool { return p == token })
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
dev := &devicectlDevice{reg: reg}
|
||||||
|
|
||||||
|
// ① 没指定设备 ⇒ 报错要**可执行**(列出在线设备),而不是含糊失败
|
||||||
|
if _, err := dev.Execute("output", map[string]interface{}{"payload": "x", "type": "text"}); err == nil {
|
||||||
|
t.Fatal("无 device_id 时应报错")
|
||||||
|
}
|
||||||
|
|
||||||
|
cli := dialTestWS(t, srv.URL, token)
|
||||||
|
defer cli.close()
|
||||||
|
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"pc-1","name":"PC","kind":"computer","caps":["cmd"]}}`))
|
||||||
|
cli.readHelloAckAndBind(t, token)
|
||||||
|
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for !reg.Online("pc-1") && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if !reg.Online("pc-1") {
|
||||||
|
t.Fatal("设备未上线")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② meta 是 JSON 且含 device_id ⇒ 投递到该设备
|
||||||
|
if _, err := dev.Execute("output", map[string]interface{}{
|
||||||
|
"payload": "hi", "type": "text", "meta": `{"device_id":"pc-1"}`,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("按 meta.device_id 投递失败: %v", err)
|
||||||
|
}
|
||||||
|
m := make(chan map[string]interface{}, 1)
|
||||||
|
go func() {
|
||||||
|
_, payload, err := cli.readMsg()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var got map[string]interface{}
|
||||||
|
if json.Unmarshal(payload, &got) == nil {
|
||||||
|
m <- got
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case got := <-m:
|
||||||
|
if got["op"] != "push" {
|
||||||
|
t.Fatalf("应为 push 帧,实际 %v", got)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("聚合通道未投递到设备")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 设备在线但指定了不存在的设备 ⇒ 报错(online 列表里有 pc-1)
|
||||||
|
if _, err := dev.Execute("output", map[string]interface{}{
|
||||||
|
"payload": "hi", "type": "text", "device_id": "ghost",
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("不存在的设备应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通道名合规性:设备通道名会被内核拼进 LLM **函数名**(output_send__<通道名>),
|
||||||
|
// 而上游函数名规范是 ^[a-zA-Z0-9_-]{1,64}$ —— 违规会让**整条请求**被 400 拒绝
|
||||||
|
// (实测把生产打挂:device/<id> 里的 `/` 触发 Invalid 'tools[299].function.name',
|
||||||
|
// 网关 auto tier 全链条失败,整个 agent 不说话了)。
|
||||||
|
//
|
||||||
|
// 通道名是**插件自己的声明**,所以这条判据钉在插件侧。
|
||||||
|
func TestDeviceChannelNameIsLLMFunctionNameSafe(t *testing.T) {
|
||||||
|
re := regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
|
||||||
|
// 含**恶意/异常** id:空格、符号、非 ASCII、超长、以及会折成同一个名字的两个 id
|
||||||
|
ids := []string{"waiter-fnnas", "1", "a b!c", "中文设备", strings.Repeat("x", 120), "a b", "a-b"}
|
||||||
|
p := &Plugin{}
|
||||||
|
seen := map[string]string{}
|
||||||
|
for _, id := range ids {
|
||||||
|
ch := p.deviceChannelName(id)
|
||||||
|
if prev, dup := seen[ch]; dup {
|
||||||
|
t.Errorf("不同设备 id(%q 与 %q)派生出同一个通道名 %q", prev, id, ch)
|
||||||
|
}
|
||||||
|
seen[ch] = id
|
||||||
|
if !re.MatchString(ch) {
|
||||||
|
t.Errorf("设备通道名 %q 违反上游函数名规范 %s", ch, re)
|
||||||
|
}
|
||||||
|
toolName := "output_send__" + ch
|
||||||
|
if !re.MatchString(toolName) {
|
||||||
|
t.Errorf("派生出的工具名 %q 违反上游函数名规范 %s", toolName, re)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -37,6 +37,11 @@ type Plugin struct {
|
|||||||
token string
|
token string
|
||||||
sdk *sdk.PluginSDK
|
sdk *sdk.PluginSDK
|
||||||
dev *devicectlDevice
|
dev *devicectlDevice
|
||||||
|
|
||||||
|
// devChansMu/devChans 维护"设备自报 id → 派生的通道名"。
|
||||||
|
// 设备 id 是外部输入,不能直接进通道名(见 outputch.go 的 deviceChannelName)。
|
||||||
|
devChansMu sync.Mutex
|
||||||
|
devChans map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(name string) *Plugin {
|
func New(name string) *Plugin {
|
||||||
@ -114,10 +119,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 设备通道随在线状态生灭(见 outputch.go 的 wireDeviceChannels)----
|
||||||
|
p.wireDeviceChannels()
|
||||||
|
|
||||||
// ---- 设备主动上报事件 → agent 注入 ----------------
|
// ---- 设备主动上报事件 → agent 注入 ----------------
|
||||||
// 摄像头发现异常/传感器报警等场景:设备经 WS op=event 上报,
|
// 摄像头发现异常/传感器报警等场景:设备经 WS op=event 上报,
|
||||||
// 插件将其格式化为文本经 SDK InjectText 异步注入 agent(source=device/{id},
|
// 插件将其格式化为文本经 SDK InjectText 异步注入 agent(source=device/{id}),
|
||||||
// 回复路由回 device/{id} 通道),同时发 EventBus 供 WebUI 展示。
|
// 同时发 EventBus 供 WebUI 展示。
|
||||||
|
//
|
||||||
|
// **注意**:agent 的输出**不会**被自动转回设备 —— 主动转发只有 webui 与 cli 两个
|
||||||
|
// 交互界面(它们把最终回复渲染成对话气泡是本职)。设备要走
|
||||||
|
// `output_send__device-<id>`(agent 主动调用),这才与"输出是 agent 的主动调用"一致。
|
||||||
// 节流:同设备同类型事件 10s 内去重,防传感器风暴。
|
// 节流:同设备同类型事件 10s 内去重,防传感器风暴。
|
||||||
lastEventAt := map[string]time.Time{}
|
lastEventAt := map[string]time.Time{}
|
||||||
var eventMu sync.Mutex
|
var eventMu sync.Mutex
|
||||||
@ -152,8 +164,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
|
|
||||||
log.Printf("[remotedevice] event from %s: %s", deviceID, evtType)
|
log.Printf("[remotedevice] event from %s: %s", deviceID, evtType)
|
||||||
if p.sdk != nil {
|
if p.sdk != nil {
|
||||||
|
// 设备通道 device-<id> 是动态的(分隔符用 - 而非 /,见 deviceChannelName 的说明:
|
||||||
|
// 通道名会进 LLM 函数名,必须满足 ^[a-zA-Z0-9_-]{1,64}$)。
|
||||||
|
// 首次上报时**懒登记** inputch(Register 幂等),父 agent 才能把它划给驻留子。
|
||||||
|
devCh := p.deviceChannelName(deviceID)
|
||||||
|
_ = p.sdk.RegisterInputChannel(devCh, sdk.ChannelDef{})
|
||||||
// 异步注入:不阻塞 WS 读循环;回复路由回 device/{id} 输出通道
|
// 异步注入:不阻塞 WS 读循环;回复路由回 device/{id} 输出通道
|
||||||
p.sdk.InjectInput("device/"+deviceID, "device/"+deviceID, "text", map[string]interface{}{"content": text})
|
p.sdk.InjectInput(devCh, devCh, "text", map[string]interface{}{"content": text})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -295,6 +312,13 @@ func (p *Plugin) describeScreen(dataURL string, provider string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) Stop() error {
|
func (p *Plugin) Stop() error {
|
||||||
|
// 注销全部设备通道:插件卸载/重载后这些通道不再有实现,
|
||||||
|
// 留着会让 output_list_channels 骗模型。
|
||||||
|
if p.sdk != nil {
|
||||||
|
for _, m := range p.registry.List() {
|
||||||
|
p.dropDeviceOutputChannel(m.DeviceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
if p.server != nil {
|
if p.server != nil {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@ -60,10 +60,14 @@ func (c *wconn) unlockWrite() {
|
|||||||
// Registry 是设备接入网关的注册表:管理在线连接、设备元数据。线程安全。
|
// Registry 是设备接入网关的注册表:管理在线连接、设备元数据。线程安全。
|
||||||
// 鉴权在设备端执行,服务端不存储授权状态。
|
// 鉴权在设备端执行,服务端不存储授权状态。
|
||||||
type Registry struct {
|
type Registry struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
devices map[string]*DeviceMeta // deviceID -> meta(在线/历史)
|
devices map[string]*DeviceMeta // deviceID -> meta(在线/历史)
|
||||||
conns map[string]*wconn // deviceID -> 活跃连接(支持 push)
|
conns map[string]*wconn // deviceID -> 活跃连接(支持 push)
|
||||||
onlineCh chan string
|
onlineCh chan string
|
||||||
|
|
||||||
|
// onOnline/onOffline:设备上下线的同步回调(见 SetPresenceHandler)。
|
||||||
|
onOnline func(DeviceMeta)
|
||||||
|
onOffline func(string)
|
||||||
onStatus func(msg map[string]interface{})
|
onStatus func(msg map[string]interface{})
|
||||||
onEvent func(deviceID string, msg map[string]interface{})
|
onEvent func(deviceID string, msg map[string]interface{})
|
||||||
acceptFn func(token string) bool
|
acceptFn func(token string) bool
|
||||||
@ -281,7 +285,12 @@ func (r *Registry) register(meta DeviceMeta) {
|
|||||||
meta.LastSeen = time.Now().Unix()
|
meta.LastSeen = time.Now().Unix()
|
||||||
// 保留设备自报的授权状态(客户端鉴权,服务端不覆盖)
|
// 保留设备自报的授权状态(客户端鉴权,服务端不覆盖)
|
||||||
r.devices[meta.DeviceID] = &meta
|
r.devices[meta.DeviceID] = &meta
|
||||||
|
onOnline := r.onOnline
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
|
// 先回调(可能注册 device-<id> 输出通道),再发变更通知。
|
||||||
|
if onOnline != nil {
|
||||||
|
onOnline(meta)
|
||||||
|
}
|
||||||
r.notifyChange(meta.DeviceID)
|
r.notifyChange(meta.DeviceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -291,10 +300,27 @@ func (r *Registry) markOffline(id string) {
|
|||||||
m.Online = false
|
m.Online = false
|
||||||
}
|
}
|
||||||
delete(r.conns, id)
|
delete(r.conns, id)
|
||||||
|
onOffline := r.onOffline
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
|
if onOffline != nil {
|
||||||
|
onOffline(id)
|
||||||
|
}
|
||||||
r.notifyChange(id)
|
r.notifyChange(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPresenceHandler 注册设备上线/下线回调。
|
||||||
|
//
|
||||||
|
// 为什么不用 ChangeChan:那是 `select { case ch <- id: default: }`,缓冲满了会**丢事件**
|
||||||
|
// (设备上下线是要跟"注册/注销输出通道"绑定的,丢一次就会留下一个死通道或漏注册)。
|
||||||
|
// 这里同步调用,且在**释放锁之后**调 —— 回调内部会回查 registry(Get/List),
|
||||||
|
// 持锁调用会自己锁死自己。
|
||||||
|
func (r *Registry) SetPresenceHandler(onOnline func(DeviceMeta), onOffline func(string)) {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.onOnline = onOnline
|
||||||
|
r.onOffline = onOffline
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Registry) notifyChange(id string) {
|
func (r *Registry) notifyChange(id string) {
|
||||||
select {
|
select {
|
||||||
case r.onlineCh <- id:
|
case r.onlineCh <- id:
|
||||||
|
|||||||
@ -63,6 +63,8 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// timer 通道:定时器到点经它注入 agent(见本文件 InjectInterruptTextOpts 调用)。
|
||||||
|
_ = s.RegisterInputChannel("timer", sdk.ChannelDef{})
|
||||||
s.RegisterTool("timer_set", sdk.ToolDef{
|
s.RegisterTool("timer_set", sdk.ToolDef{
|
||||||
Name: "timer_set",
|
Name: "timer_set",
|
||||||
Description: "设置一个定时提醒。倒计时结束后通过中断通道通知 agent。",
|
Description: "设置一个定时提醒。倒计时结束后通过中断通道通知 agent。",
|
||||||
@ -108,8 +110,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
case <-time.After(dur):
|
case <-time.After(dur):
|
||||||
log.Printf("[timer] firing: %s (%s later)", message, dur)
|
log.Printf("[timer] firing: %s (%s later)", message, dur)
|
||||||
// NoMemory:定时提醒是系统通知,不是记忆内容。
|
// NoMemory:定时提醒是系统通知,不是记忆内容。
|
||||||
|
// PriorityL3:定时器是“时钟那种实时工作”——到点就该处理,
|
||||||
|
// 比 QQ 那类可无限等待的异步消息高(L3 vs L1)。
|
||||||
s.InjectInterruptTextOpts("timer", "timer", fmt.Sprintf("timer: %s", message),
|
s.InjectInterruptTextOpts("timer", "timer", fmt.Sprintf("timer: %s", message),
|
||||||
sdk.InjectOptions{NoMemory: true})
|
sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL3})
|
||||||
case <-p.stopCh:
|
case <-p.stopCh:
|
||||||
log.Printf("[timer] cancelled: %s", message)
|
log.Printf("[timer] cancelled: %s", message)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1812,8 +1812,12 @@ func (h *Handler) handleChatInterrupt(w http.ResponseWriter, r *http.Request) {
|
|||||||
if body.DeviceID != "" {
|
if body.DeviceID != "" {
|
||||||
source = "webui/" + body.DeviceID
|
source = "webui/" + body.DeviceID
|
||||||
}
|
}
|
||||||
|
// PriorityL4:终止按钮必须能立即打断当前任务(内核级插件才有的能力)。
|
||||||
|
// agent 正卡在工具执行里时按不下手——那是临界区,由内核在安全点生效;
|
||||||
|
// 但 LLM 流式段会被立刻取消。
|
||||||
h.sdk.InjectInterrupt(source, "webui", "text", map[string]interface{}{
|
h.sdk.InjectInterrupt(source, "webui", "text", map[string]interface{}{
|
||||||
"content": body.Message,
|
"content": body.Message,
|
||||||
|
"priority": sdk.PriorityL4,
|
||||||
})
|
})
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "interrupted"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "interrupted"})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -148,6 +148,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 能力位 7 = CapText|CapFile|CapImage;旧值 1 仅文本,agent 无法向 webui 发文件/图片
|
// 能力位 7 = CapText|CapFile|CapImage;旧值 1 仅文本,agent 无法向 webui 发文件/图片
|
||||||
|
// 入站通道:webui(控制台对话)与 http(外部 HTTP 注入),都由本插件注入输入。
|
||||||
|
// http 通道还声明 NoMemory:外部抓来的内容不进记忆计算(见 handler 里的 NoMemory 注入)。
|
||||||
|
_ = s.RegisterInputChannel("webui", sdk.ChannelDef{})
|
||||||
|
_ = s.RegisterInputChannel("http", sdk.ChannelDef{NoMemory: true})
|
||||||
s.RegisterOutputChannel("webui", 7, "Web 控制台(支持文字/图片/文件,图片内联展示、文件可下载)", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
|
s.RegisterOutputChannel("webui", 7, "Web 控制台(支持文字/图片/文件,图片内联展示、文件可下载)", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
payload, _ := args["payload"].(string)
|
payload, _ := args["payload"].(string)
|
||||||
rawType, _ := args["type"].(string)
|
rawType, _ := args["type"].(string)
|
||||||
|
|||||||
@ -50,9 +50,19 @@ const (
|
|||||||
|
|
||||||
type APIRegistrar = pubsdk.APIRegistrar
|
type APIRegistrar = pubsdk.APIRegistrar
|
||||||
type OutputChannelRegistrar = pubsdk.OutputChannelRegistrar
|
type OutputChannelRegistrar = pubsdk.OutputChannelRegistrar
|
||||||
|
type OutputChannelUnregistrar = pubsdk.OutputChannelUnregistrar
|
||||||
type InputChannelRegistrar = pubsdk.InputChannelRegistrar
|
type InputChannelRegistrar = pubsdk.InputChannelRegistrar
|
||||||
type ChannelDef = pubsdk.ChannelDef
|
type ChannelDef = pubsdk.ChannelDef
|
||||||
|
|
||||||
|
// 中断优先级的取值再导出:内置插件用 sdk.PriorityL4 声明“立即打断”,
|
||||||
|
// 外部插件同名常量会被内核夹到 L3(见 core.interruptLevel / proc 桥)。
|
||||||
|
const (
|
||||||
|
PriorityL1 = pubsdk.PriorityL1
|
||||||
|
PriorityL2 = pubsdk.PriorityL2
|
||||||
|
PriorityL3 = pubsdk.PriorityL3
|
||||||
|
PriorityL4 = pubsdk.PriorityL4
|
||||||
|
)
|
||||||
|
|
||||||
// InjectOptions / 上下文策略常量:内置插件与外部插件必须用同一套类型与取值,
|
// InjectOptions / 上下文策略常量:内置插件与外部插件必须用同一套类型与取值,
|
||||||
// 否则内核要认两份,而漏认会静默丢失标志位。
|
// 否则内核要认两份,而漏认会静默丢失标志位。
|
||||||
type InjectOptions = pubsdk.InjectOptions
|
type InjectOptions = pubsdk.InjectOptions
|
||||||
@ -275,20 +285,21 @@ type AudioURL = pubsdk.AudioURL
|
|||||||
|
|
||||||
// SDKConfig holds all dependencies for creating a PluginSDK.
|
// SDKConfig holds all dependencies for creating a PluginSDK.
|
||||||
type SDKConfig struct {
|
type SDKConfig struct {
|
||||||
IOManager *agentIO.IOManager
|
IOManager *agentIO.IOManager
|
||||||
EventBus *events.Bus
|
EventBus *events.Bus
|
||||||
Memory MemoryAPI
|
Memory MemoryAPI
|
||||||
TextMemory TextMemoryAPI
|
TextMemory TextMemoryAPI
|
||||||
DocMemory DocMemoryAPI
|
DocMemory DocMemoryAPI
|
||||||
Knowledge KnowledgeAPI
|
Knowledge KnowledgeAPI
|
||||||
LLM LLMAPI
|
LLM LLMAPI
|
||||||
Settings SettingsAPI
|
Settings SettingsAPI
|
||||||
RegTool ToolRegistrar
|
RegTool ToolRegistrar
|
||||||
RegStage StageRegistrar
|
RegStage StageRegistrar
|
||||||
RegAPI APIRegistrar
|
RegAPI APIRegistrar
|
||||||
RegOutput OutputChannelRegistrar
|
RegOutput OutputChannelRegistrar
|
||||||
RegInput InputChannelRegistrar
|
RegOutputUnreg OutputChannelUnregistrar
|
||||||
PluginMgr PluginManager
|
RegInput InputChannelRegistrar
|
||||||
|
PluginMgr PluginManager
|
||||||
|
|
||||||
Status StatusAPI
|
Status StatusAPI
|
||||||
Supervisor SupervisorAPI
|
Supervisor SupervisorAPI
|
||||||
@ -307,6 +318,9 @@ func New(name string, cfg SDKConfig) *PluginSDK {
|
|||||||
if cfg.RegInput != nil {
|
if cfg.RegInput != nil {
|
||||||
base.SetInputChannelRegistrar(cfg.RegInput)
|
base.SetInputChannelRegistrar(cfg.RegInput)
|
||||||
}
|
}
|
||||||
|
if cfg.RegOutputUnreg != nil {
|
||||||
|
base.SetOutputChannelUnregistrar(cfg.RegOutputUnreg)
|
||||||
|
}
|
||||||
base.SetMemoryAPI(cfg.Memory)
|
base.SetMemoryAPI(cfg.Memory)
|
||||||
base.SetTextMemoryAPI(cfg.TextMemory)
|
base.SetTextMemoryAPI(cfg.TextMemory)
|
||||||
base.SetDocMemoryAPI(cfg.DocMemory)
|
base.SetDocMemoryAPI(cfg.DocMemory)
|
||||||
|
|||||||
@ -46,6 +46,36 @@ type KernelStatus struct {
|
|||||||
ONNX ONNXStatus `json:"onnx"`
|
ONNX ONNXStatus `json:"onnx"`
|
||||||
|
|
||||||
Tracker TrackerStatus `json:"tracker"`
|
Tracker TrackerStatus `json:"tracker"`
|
||||||
|
|
||||||
|
// Scheduler 是输入调度器的运行时快照(可观测性,设计文档 §11 O1/O2)。
|
||||||
|
// M2 起输入不再直接排队在 channel 上,而是经 readyQueue/pendingInterrupts/
|
||||||
|
// suspendStack 三集合按优先级调度;这里把这些状态暴露出来。
|
||||||
|
Scheduler SchedulerStatus `json:"scheduler"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchedulerStatus 是调度器的原子快照 DTO。
|
||||||
|
type SchedulerStatus struct {
|
||||||
|
// Running 是当前执行的任务(空表示空闲)。
|
||||||
|
Running *SchedulerTask `json:"running,omitempty"`
|
||||||
|
// ReadyQueueDepth / PendingInterrupts / SuspendStack 是三个集合的深度。
|
||||||
|
ReadyQueueDepth int `json:"ready_queue_depth"`
|
||||||
|
PendingInterrupts int `json:"pending_interrupts"`
|
||||||
|
SuspendStack int `json:"suspend_stack"`
|
||||||
|
MaxSuspendDepth int `json:"max_suspend_depth"`
|
||||||
|
|
||||||
|
Enqueued uint64 `json:"enqueued"`
|
||||||
|
Executed uint64 `json:"executed"`
|
||||||
|
Rejected uint64 `json:"rejected"`
|
||||||
|
Suspended uint64 `json:"suspended"`
|
||||||
|
Resumed uint64 `json:"resumed"`
|
||||||
|
Preempted uint64 `json:"preempted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchedulerTask 是任务的最小标识(不暴露帧内容)。
|
||||||
|
type SchedulerTask struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Level int `json:"level"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ONNXStatus 是统一多模态向量空间(ONNX 模型)的启用状态与身份。
|
// ONNXStatus 是统一多模态向量空间(ONNX 模型)的启用状态与身份。
|
||||||
|
|||||||
11
plan.md
11
plan.md
@ -1369,11 +1369,16 @@ SSE Last-Event-ID → 超时 → api 状态码 → renderAll 增量 → XSS 消
|
|||||||
|
|
||||||
### 13.12 L3 原生多模态
|
### 13.12 L3 原生多模态
|
||||||
|
|
||||||
|
**目标纠正**:现有 `media_refs` 与 `graph_sentence` 只是挂载/反查机制;从文本标记生成普通 `Media` 三元组也不是一等多模态图节点。目标是媒体拥有独立身份、模态、MIME、原生向量、模型 fingerprint 与生命周期,并通过结构化边直接参与图检索、文档检索和 context 语义淘汰。
|
||||||
|
|
||||||
**实施**:
|
**实施**:
|
||||||
|
|
||||||
1. L3 node_type 新增 media/media_block
|
1. L3 建立结构化 media/media_block 一等节点(不从描述文本反解)
|
||||||
2. L3 edge_type 新增 depicts/contains
|
2. L3 建立 contains/depicts/derived_from 原生边,可连接 document/context/entity/media
|
||||||
3. L2→L3 迁移时保留 media 引用边
|
3. L2→L3 迁移时把文档及其媒体变成节点与边,不只迁移 `media_refs`
|
||||||
|
4. multimodal doc 的向量由文本及其媒体块在同一 Qwen fingerprint 空间共同参与
|
||||||
|
5. multimodal context 的裁剪查询与事件向量同时融合 text/image/video,而非只算文本字段
|
||||||
|
6. text/image/video 必须来自同一个 Qwen 空间;音频在该模型下明确 unsupported
|
||||||
|
|
||||||
**验证**:
|
**验证**:
|
||||||
|
|
||||||
|
|||||||
87
scripts/kernel-stress/README.md
Normal file
87
scripts/kernel-stress/README.md
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
# 内核二进制压力测试(kernel-stress)
|
||||||
|
|
||||||
|
对本仓库**编译出来的真实内核**做压力测试 —— 与 `go test` 的区别是:它跑真二进制、
|
||||||
|
真插件加载、真 unix socket 协议,因此能抓到只在集成面上出现的问题
|
||||||
|
(已有战绩:根 agent `DataDir` 漏接线、插件通道没登记为 inputch、create 后子不开工)。
|
||||||
|
|
||||||
|
## 为什么必须放在私有 netns 里
|
||||||
|
|
||||||
|
生产实例占着 `*:8080 / *:9890 / *:9876`,而插件的监听都设了 `SO_REUSEADDR`:
|
||||||
|
同机再起一个实例会**在 127.0.0.1 上与之并存绑定**(实测抢到过 `127.0.0.1:9890` 约 1 分钟)。
|
||||||
|
`unshare -n` 后实例只有 `lo`,结构上不可能碰到生产端口。
|
||||||
|
unix socket 是文件系统对象,跨 netns 仍可驱动,所以驱动脚本在 netns 外也能用。
|
||||||
|
|
||||||
|
## 前置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o /tmp/homed-stress ./cmd/homed # 被压的内核
|
||||||
|
export GOCACHE=/tmp/gocache GOPATH=/tmp/gopath TMPDIR=/var/tmp/gotmp
|
||||||
|
```
|
||||||
|
|
||||||
|
## 用法
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 准备数据目录 + 把 LLM 指向本地 mock(无外网也能跑,且快、可控)
|
||||||
|
DATA=/var/tmp/kstress
|
||||||
|
mkdir -p $DATA
|
||||||
|
# 先跑一次实例建出 config.db,再写入下面这些键(也可直接复用现成目录):
|
||||||
|
# core.llm.provider=mock
|
||||||
|
# core.llm.sources.mock.base_url=http://127.0.0.1:9099/v1
|
||||||
|
# core.llm.sources.mock.model=mock api_key=mock adapter=openai
|
||||||
|
# core.llm.sources.mock.adapter_path=adapters/openai.lua priority=100
|
||||||
|
# core.defaults.llm_endpoints=http://127.0.0.1:9099/v1/models # 探活端点(探活用 HEAD!)
|
||||||
|
# core.defaults.rollback.max_retries=100000 auto_rollback=false
|
||||||
|
# 并把 core.llm.sources.deepseek* 删掉(netns 里它不可达,会让 agent 被判 degraded → rollback 循环)
|
||||||
|
|
||||||
|
# 2) 起 mock LLM + 内核(都在同一个私有 netns 里)
|
||||||
|
MOCK_DELAY_MS=300 MOCK_CHUNKS=8 ./launch.sh /tmp/homed-stress
|
||||||
|
|
||||||
|
# 3) 取认证密钥(cli 插件回落到 webui.api_key)
|
||||||
|
export KCLI_KEY=$(sqlite3 $DATA/config.db "select value from config_webui where key='api_key';")
|
||||||
|
|
||||||
|
# 4) 压
|
||||||
|
./kcli.py $DATA/cli.sock stats full # 看内核状态(含 scheduler 计数)
|
||||||
|
./stress.py $DATA/cli.sock "$KCLI_KEY" 16 12 4 20 dense 0.05 # 16 连接×12 输入 + 4 线程×20 中断
|
||||||
|
./stress.py $DATA/cli.sock "$KCLI_KEY" 12 12 1 25 0.0 3.0 # 稀疏中断 ⇒ 压抢占/挂起/恢复
|
||||||
|
./stress.py $DATA/cli.sock "$KCLI_KEY" 1 1 0 0 resident 0.0 # 驻留子全链路(mock 见 !resident 标记)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 远程设备(agent ↔ 设备)端到端
|
||||||
|
|
||||||
|
设备网关在实例的 netns 内监听 `127.0.0.1:9890`,所以设备客户端要**进同一个 netns** 跑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=$(sqlite3 $DATA/config.db "select value from config_remotedevice where key='ws_token';")
|
||||||
|
nsenter -t $(cat $DATA/pid) -n python3 ./devclient.py --port 9890 --token "$TOKEN" \
|
||||||
|
--id pydev-1 --caps cmd --seconds 30 --out /var/tmp/push.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
然后让 agent 主动发一条(mock 里 `!push` 会回一个 `output_send__device/pydev-1` 的工具调用):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 经 CLI socket 发 "!push",设备侧应收到 {"op":"push", ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
设备上线/下线会在内核里登记/注销输出通道 `device/<id>`,可用 `/kernel` 的 channels 观察
|
||||||
|
(在线时出现、掉线后消失)。
|
||||||
|
|
||||||
|
> **设计口径**:agent → 设备**必须**是 agent 的主动调用(`output_send__device/<id>`);
|
||||||
|
> 设备的上报(`op=event`)虽然会被注入成输入,但 agent 的回复**不会**被插件自动转回设备
|
||||||
|
> —— 全仓只有 **webui 与 cli** 两个交互界面"主动转发"(把最终回复渲染成气泡/终端输出),
|
||||||
|
> 其它通道(qq、设备等)一律要求显式 `output_send__<通道>`。
|
||||||
|
|
||||||
|
## 读结果
|
||||||
|
|
||||||
|
| 指标 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `executed` / `rejected` | 任务执行数 / 被拒数(压力下应为 0) |
|
||||||
|
| 峰值 `峰值_队列` / `峰值_待处理中断` | 采样到的最大排队深度 / 待处理中断数 |
|
||||||
|
| `suspended` / `resumed` / `preempted` | 抢占三件套。**中断要放稀**才会打在高优先级任务上:密集中断会互相同级(L4 vs L4)不抢占,数值会很低 |
|
||||||
|
| `max_suspend_depth` | 中断栈结构上界(= 中断级数 4) |
|
||||||
|
|
||||||
|
## 已知坑
|
||||||
|
|
||||||
|
- 探活用 **HEAD**(`internal/network/monitor.go`):mock 必须实现 `do_HEAD`,否则 501 → 判不可达 → agent degraded → rollback 循环(会 reset 合并目录)。
|
||||||
|
- CLI 协议**每条连接串行**处理(`handleChat` 阻塞到本次响应产出):单连接狂发只会排成一条线,造不出队列压力 —— 必须多连接。
|
||||||
|
- 认证:连接后先发 `/auth <key>`,否则一切命令返回 `unauthorized`。
|
||||||
|
- `mockllm.py` 的 `!resident` / `!notify` 标记用来让 mock 回工具调用,从而在真内核里驱动 `resident_agents` / `notify_parent`。
|
||||||
160
scripts/kernel-stress/devclient.py
Normal file
160
scripts/kernel-stress/devclient.py
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""最小远程设备客户端(WebSocket,无第三方依赖)。
|
||||||
|
|
||||||
|
用途:在**真内核**上验证"agent → 设备"这条出站链路 ——
|
||||||
|
`output_send__device/<id>` 应该以 `op=push` 的帧落到设备。
|
||||||
|
|
||||||
|
流程:握手 → hello(自报 id/kind/caps)→ bind → 打印收到的帧;
|
||||||
|
看到 push 就往 --out 文件里写一行(便于 shell 断言)。
|
||||||
|
|
||||||
|
注意:实例跑在私有 netns 里,设备网关的 127.0.0.1:9890 在 netns 内,
|
||||||
|
所以本脚本要用 nsenter 进同一个 netns 跑,例如:
|
||||||
|
nsenter -t <homed-pid> -n python3 devclient.py --port 9890 --token X --id pydev-1 --out /tmp/push.txt
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||||
|
|
||||||
|
|
||||||
|
class WS:
|
||||||
|
def __init__(self, host, port, path, timeout=30):
|
||||||
|
self.sock = socket.create_connection((host, port), timeout=timeout)
|
||||||
|
self.sock.settimeout(timeout)
|
||||||
|
self._handshake(host, port, path)
|
||||||
|
|
||||||
|
def _handshake(self, host, port, path):
|
||||||
|
key = base64.b64encode(os.urandom(16)).decode()
|
||||||
|
req = (
|
||||||
|
f"GET {path} HTTP/1.1\r\n"
|
||||||
|
f"Host: {host}:{port}\r\n"
|
||||||
|
"Upgrade: websocket\r\n"
|
||||||
|
"Connection: Upgrade\r\n"
|
||||||
|
f"Sec-WebSocket-Key: {key}\r\n"
|
||||||
|
"Sec-WebSocket-Version: 13\r\n\r\n"
|
||||||
|
)
|
||||||
|
self.sock.sendall(req.encode())
|
||||||
|
buf = b""
|
||||||
|
while b"\r\n\r\n" not in buf:
|
||||||
|
chunk = self.sock.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
raise RuntimeError("握手未完成: 连接关闭")
|
||||||
|
buf += chunk
|
||||||
|
head = buf.decode("utf-8", "replace")
|
||||||
|
if "101" not in head.split("\r\n")[0]:
|
||||||
|
raise RuntimeError("握手被拒: " + head.split("\r\n")[0])
|
||||||
|
expect = base64.b64encode(hashlib.sha1((key + GUID).encode()).digest()).decode()
|
||||||
|
if expect.lower() not in head.lower():
|
||||||
|
raise RuntimeError("Sec-WebSocket-Accept 校验失败")
|
||||||
|
self.buf = buf.split(b"\r\n\r\n", 1)[1]
|
||||||
|
|
||||||
|
# ---- 发送 ----
|
||||||
|
def send(self, opcode, payload=b""):
|
||||||
|
header = bytes([0x80 | opcode])
|
||||||
|
mask = os.urandom(4)
|
||||||
|
n = len(payload)
|
||||||
|
if n < 126:
|
||||||
|
header += bytes([0x80 | n])
|
||||||
|
elif n < 65536:
|
||||||
|
header += bytes([0x80 | 126]) + struct.pack(">H", n)
|
||||||
|
else:
|
||||||
|
header += bytes([0x80 | 127]) + struct.pack(">Q", n)
|
||||||
|
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
||||||
|
self.sock.sendall(header + mask + masked)
|
||||||
|
|
||||||
|
def send_json(self, obj):
|
||||||
|
self.send(0x1, json.dumps(obj).encode())
|
||||||
|
|
||||||
|
# ---- 接收 ----
|
||||||
|
def _read(self, n):
|
||||||
|
while len(self.buf) < n:
|
||||||
|
chunk = self.sock.recv(65536)
|
||||||
|
if not chunk:
|
||||||
|
raise RuntimeError("连接关闭")
|
||||||
|
self.buf += chunk
|
||||||
|
out, self.buf = self.buf[:n], self.buf[n:]
|
||||||
|
return out
|
||||||
|
|
||||||
|
def recv_frame(self):
|
||||||
|
b0, b1 = self._read(2)
|
||||||
|
opcode = b0 & 0x0F
|
||||||
|
ln = b1 & 0x7F
|
||||||
|
if ln == 126:
|
||||||
|
ln = struct.unpack(">H", self._read(2))[0]
|
||||||
|
elif ln == 127:
|
||||||
|
ln = struct.unpack(">Q", self._read(8))[0]
|
||||||
|
masked = b1 & 0x80
|
||||||
|
mask = self._read(4) if masked else None
|
||||||
|
payload = self._read(ln)
|
||||||
|
if mask:
|
||||||
|
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
||||||
|
return opcode, payload
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--host", default="127.0.0.1")
|
||||||
|
ap.add_argument("--port", type=int, default=9890)
|
||||||
|
ap.add_argument("--token", required=True, help="设备接入 token(config_remotedevice.ws_token)")
|
||||||
|
ap.add_argument("--id", default="pydev-1")
|
||||||
|
ap.add_argument("--kind", default="computer")
|
||||||
|
ap.add_argument("--caps", default="cmd")
|
||||||
|
ap.add_argument("--seconds", type=float, default=25)
|
||||||
|
ap.add_argument("--out", default="", help="收到 push 时写一行到此文件")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
ws = WS(args.host, args.port, "/api/v1/device/ws?token=" + args.token)
|
||||||
|
ws.send_json({"op": "hello", "device": {
|
||||||
|
"device_id": args.id, "name": "python 设备", "kind": args.kind,
|
||||||
|
"caps": [c for c in args.caps.split(",") if c],
|
||||||
|
}})
|
||||||
|
op, payload = ws.recv_frame()
|
||||||
|
ack = json.loads(payload)
|
||||||
|
print("[devclient] hello_ack:", ack, flush=True)
|
||||||
|
|
||||||
|
ws.send_json({"op": "bind", "device_id": args.id, "token": args.token})
|
||||||
|
op, payload = ws.recv_frame()
|
||||||
|
bind = json.loads(payload)
|
||||||
|
print("[devclient] bind_ack:", bind, flush=True)
|
||||||
|
if not bind.get("ok"):
|
||||||
|
raise SystemExit("bind 被拒: %s" % bind)
|
||||||
|
|
||||||
|
deadline = time.time() + args.seconds
|
||||||
|
ws.sock.settimeout(1.0)
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
op, payload = ws.recv_frame()
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
except Exception as e: # 服务端关闭
|
||||||
|
print("[devclient] 连接结束:", e, flush=True)
|
||||||
|
break
|
||||||
|
if op == 0x9: # ping → pong
|
||||||
|
ws.send(0xA, payload)
|
||||||
|
continue
|
||||||
|
if op == 0x2:
|
||||||
|
print("[devclient] 收到二进制帧 %d 字节" % len(payload), flush=True)
|
||||||
|
continue
|
||||||
|
if op != 0x1:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
m = json.loads(payload)
|
||||||
|
except Exception:
|
||||||
|
print("[devclient] 非 JSON 帧:", payload[:120], flush=True)
|
||||||
|
continue
|
||||||
|
print("[devclient] 收到帧:", json.dumps(m, ensure_ascii=False)[:300], flush=True)
|
||||||
|
if m.get("op") == "push" and args.out:
|
||||||
|
with open(args.out, "a") as f:
|
||||||
|
f.write(json.dumps(m, ensure_ascii=False) + "\n")
|
||||||
|
print("[devclient] ✅ 收到 push(agent → 设备链路通)", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
81
scripts/kernel-stress/kcli.py
Normal file
81
scripts/kernel-stress/kcli.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import json, os, socket, sys, threading, time
|
||||||
|
|
||||||
|
SOCK = sys.argv[1]
|
||||||
|
|
||||||
|
KEY = os.environ.get("KCLI_KEY", "")
|
||||||
|
|
||||||
|
def connect(auth=True):
|
||||||
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(20)
|
||||||
|
s.connect(SOCK)
|
||||||
|
if auth and KEY:
|
||||||
|
s.sendall(("/auth %s\n" % KEY).encode())
|
||||||
|
buf = b""
|
||||||
|
while b"\n" not in buf:
|
||||||
|
c = s.recv(65536)
|
||||||
|
if not c:
|
||||||
|
break
|
||||||
|
buf += c
|
||||||
|
if b"authenticated" not in buf:
|
||||||
|
raise RuntimeError("auth failed: %r" % buf[:120])
|
||||||
|
return s
|
||||||
|
|
||||||
|
def kernel_stats(sock=None, timeout=15):
|
||||||
|
"""连一条新连接查 /kernel,返回解析后的 dict。"""
|
||||||
|
s = sock or connect()
|
||||||
|
try:
|
||||||
|
s.sendall(b"/kernel\n")
|
||||||
|
buf = b""
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
chunk = s.recv(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buf += chunk
|
||||||
|
for line in buf.split(b"\n"):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
obj = json.loads(line.decode("utf-8", "replace"))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if obj.get("type") == "response" and obj.get("content", "").lstrip().startswith("{"):
|
||||||
|
return json.loads(obj["content"])
|
||||||
|
raise RuntimeError("no /kernel response")
|
||||||
|
finally:
|
||||||
|
if sock is None:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
def blast(n_queued, n_interrupt, tag):
|
||||||
|
"""一条连接狂发:排队输入与中断交错。响应在后台线程里丢弃,避免缓冲阻塞。"""
|
||||||
|
s = connect()
|
||||||
|
threading.Thread(target=lambda: drain(s), daemon=True).start()
|
||||||
|
sent = 0
|
||||||
|
for i in range(max(n_queued, n_interrupt)):
|
||||||
|
if i < n_queued:
|
||||||
|
s.sendall(("queued-%s-%d\n" % (tag, i)).encode())
|
||||||
|
sent += 1
|
||||||
|
if i < n_interrupt:
|
||||||
|
s.sendall(("/interrupt intr-%s-%d\n" % (tag, i)).encode())
|
||||||
|
sent += 1
|
||||||
|
return sent
|
||||||
|
|
||||||
|
def drain(s):
|
||||||
|
try:
|
||||||
|
while s.recv(65536):
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cmd = sys.argv[2] if len(sys.argv) > 2 else "stats"
|
||||||
|
if cmd == "stats":
|
||||||
|
st = kernel_stats()
|
||||||
|
if len(sys.argv) > 3 and sys.argv[3] == "full":
|
||||||
|
print(json.dumps(st, ensure_ascii=False))
|
||||||
|
else:
|
||||||
|
sched = st.get("scheduler") or st
|
||||||
|
print(json.dumps(sched, ensure_ascii=False))
|
||||||
|
elif cmd == "blast":
|
||||||
|
n = blast(int(sys.argv[3]), int(sys.argv[4]), sys.argv[5] if len(sys.argv) > 5 else "b")
|
||||||
|
print("sent=%d" % n)
|
||||||
17
scripts/kernel-stress/launch.sh
Executable file
17
scripts/kernel-stress/launch.sh
Executable file
@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 在**私有 netns** 里启动:mock LLM + 完整内核实例。
|
||||||
|
# 私有 netns 的意义:生产实例占着 *:8080 / *:9890 / *:9876,插件设了 SO_REUSEADDR,
|
||||||
|
# 同机再起一个实例会在 127.0.0.1 上与之并存绑定(第一次实测就这么抢到了 9890 约 1 分钟)。
|
||||||
|
# netns 里只有 lo,结构上不可能碰到生产端口。
|
||||||
|
set -e
|
||||||
|
DATA=/var/tmp/gotmp/kstress
|
||||||
|
DRIVE=/var/tmp/gotmp/kstress-drive
|
||||||
|
DELAY=${MOCK_DELAY_MS:-300}
|
||||||
|
CHUNKS=${MOCK_CHUNKS:-8}
|
||||||
|
unshare -n bash -c "
|
||||||
|
ip link set lo up
|
||||||
|
nohup env MOCK_DELAY_MS=$DELAY MOCK_CHUNKS=$CHUNKS python3 $DRIVE/mockllm.py > $DATA/mock.log 2>&1 &
|
||||||
|
echo \$! > $DATA/mock.pid
|
||||||
|
nohup $1 -data $DATA -webui 127.0.0.1:18080 > $DATA/boot.log 2>&1 &
|
||||||
|
echo \$! > $DATA/pid
|
||||||
|
"
|
||||||
102
scripts/kernel-stress/mockllm.py
Normal file
102
scripts/kernel-stress/mockllm.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""最小 OpenAI 兼容 mock:可控延迟 + SSE 分块 + 可选工具调用。
|
||||||
|
|
||||||
|
用途:给压力测试一个**快且可控**的 LLM —— 没有它,无外网的 netns 里每条输入
|
||||||
|
都要走 provider 重试(≈2 分钟/条),既慢又压不出调度器行为。
|
||||||
|
"""
|
||||||
|
import json, os, time
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
DELAY_MS = int(os.environ.get("MOCK_DELAY_MS", "300"))
|
||||||
|
CHUNKS = int(os.environ.get("MOCK_CHUNKS", "8")) # SSE 分块数:越多,流式段越长(可被中断的窗口越大)
|
||||||
|
|
||||||
|
|
||||||
|
class H(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _json(self, obj, code=200):
|
||||||
|
b = json.dumps(obj).encode()
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(b)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b)
|
||||||
|
|
||||||
|
def do_HEAD(self):
|
||||||
|
# 内核探活用 HEAD(见 internal/network/monitor.go CheckOnce):
|
||||||
|
# 不实现它 → BaseHTTPRequestHandler 回 501 → 判为不可达 → agent degraded/rollback。
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Length", "0")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
self._json({"object": "list", "data": [{"id": "mock", "object": "model"}]})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
n = int(self.headers.get("Content-Length", "0"))
|
||||||
|
body = json.loads(self.rfile.read(n) or b"{}")
|
||||||
|
msgs = body.get("messages") or []
|
||||||
|
text = ""
|
||||||
|
for m in reversed(msgs):
|
||||||
|
if m.get("role") == "user":
|
||||||
|
c = m.get("content")
|
||||||
|
text = c if isinstance(c, str) else json.dumps(c, ensure_ascii=False)
|
||||||
|
break
|
||||||
|
|
||||||
|
# 标记 !resident ⇒ 回一个工具调用,用于在**真实内核**里驱动驻留子工具链。
|
||||||
|
if "!resident" in text and not any(m.get("role") == "tool" for m in msgs):
|
||||||
|
tc = {"id": "call_mock_1", "type": "function",
|
||||||
|
"function": {"name": "resident_agents",
|
||||||
|
"arguments": json.dumps({"action": "create", "id": "r1",
|
||||||
|
"task_prompt": "驻留子任务:统计一下 !notify",
|
||||||
|
"input_chs": "cli"}, ensure_ascii=False)}}
|
||||||
|
return self._respond(body, content=None, tool_calls=[tc])
|
||||||
|
if "!notify" in text and not any(m.get("role") == "tool" for m in msgs):
|
||||||
|
tc = {"id": "call_mock_2", "type": "function",
|
||||||
|
"function": {"name": "notify_parent",
|
||||||
|
"arguments": json.dumps({"text": "mock 汇报:子已完成统计"}, ensure_ascii=False)}}
|
||||||
|
return self._respond(body, content=None, tool_calls=[tc])
|
||||||
|
return self._respond(body, content="mock-ok:" + text[:40])
|
||||||
|
|
||||||
|
def _respond(self, body, content=None, tool_calls=None):
|
||||||
|
if body.get("stream"):
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
|
self.send_header("Cache-Control", "no-cache")
|
||||||
|
self.send_header("Transfer-Encoding", "chunked")
|
||||||
|
self.end_headers()
|
||||||
|
def emit(delta):
|
||||||
|
data = json.dumps({"id": "mock", "object": "chat.completion.chunk",
|
||||||
|
"model": "mock", "choices": [{"index": 0, "delta": delta}]})
|
||||||
|
self._chunk(("data: " + data + "\n\n").encode())
|
||||||
|
if tool_calls:
|
||||||
|
emit({"role": "assistant", "tool_calls": tool_calls})
|
||||||
|
time.sleep(DELAY_MS / 1000.0)
|
||||||
|
if content:
|
||||||
|
emit({"role": "assistant", "content": content[: max(1, len(content) // CHUNKS)]})
|
||||||
|
per = max(1, len(content) // CHUNKS)
|
||||||
|
for i in range(per, len(content), per):
|
||||||
|
time.sleep(DELAY_MS / 1000.0 / CHUNKS)
|
||||||
|
emit({"content": content[i:i + per]})
|
||||||
|
self._chunk(b"data: [DONE]\n\n")
|
||||||
|
self._chunk(b"")
|
||||||
|
return
|
||||||
|
msg = {"role": "assistant", "content": content}
|
||||||
|
if tool_calls:
|
||||||
|
msg["tool_calls"] = tool_calls
|
||||||
|
msg["content"] = None
|
||||||
|
time.sleep(DELAY_MS / 1000.0)
|
||||||
|
self._json({"id": "mock", "object": "chat.completion", "model": "mock",
|
||||||
|
"choices": [{"index": 0, "message": msg, "finish_reason": "stop"}],
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 10, "total_tokens": 20}})
|
||||||
|
|
||||||
|
def _chunk(self, b):
|
||||||
|
self.wfile.write(("%x\r\n" % len(b)).encode() + b + b"\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
port = int(os.environ.get("MOCK_PORT", "9099"))
|
||||||
|
ThreadingHTTPServer(("127.0.0.1", port), H).serve_forever()
|
||||||
144
scripts/kernel-stress/stress.py
Normal file
144
scripts/kernel-stress/stress.py
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""二进制级调度器压力:多并发连接轰炸排队输入 + L4 中断,并采样峰值。
|
||||||
|
|
||||||
|
用法: stress.py <sock> <key> <conns> <inputs_per_conn> <interrupt_threads> <interrupts_each> [resident]
|
||||||
|
"""
|
||||||
|
import json, os, socket, sys, threading, time
|
||||||
|
|
||||||
|
SOCK, KEY = sys.argv[1], sys.argv[2]
|
||||||
|
NC, NI, IT, IE = (int(x) for x in sys.argv[3:7])
|
||||||
|
MODE = sys.argv[7] if len(sys.argv) > 7 else ""
|
||||||
|
GAP = float(sys.argv[8]) if len(sys.argv) > 8 else 0.05
|
||||||
|
|
||||||
|
lock = threading.Lock()
|
||||||
|
sent = 0
|
||||||
|
done = 0
|
||||||
|
errs = []
|
||||||
|
lat = []
|
||||||
|
peak = {"queue": 0, "pending": 0, "stack": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def connect():
|
||||||
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(60)
|
||||||
|
s.connect(SOCK)
|
||||||
|
s.sendall(("/auth %s\n" % KEY).encode())
|
||||||
|
buf = b""
|
||||||
|
while b"\n" not in buf:
|
||||||
|
buf += s.recv(65536)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def chat(s, text):
|
||||||
|
"""发一条输入并等到终止帧(response/error)。"""
|
||||||
|
s.sendall((text + "\n").encode())
|
||||||
|
buf = b""
|
||||||
|
while True:
|
||||||
|
c = s.recv(65536)
|
||||||
|
if not c:
|
||||||
|
raise RuntimeError("closed")
|
||||||
|
buf += c
|
||||||
|
while b"\n" in buf:
|
||||||
|
ln, buf = buf.split(b"\n", 1)
|
||||||
|
if not ln.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
obj = json.loads(ln.decode("utf-8", "replace"))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if obj.get("type") in ("response", "error"):
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def worker(idx, n):
|
||||||
|
global sent, done
|
||||||
|
try:
|
||||||
|
s = connect()
|
||||||
|
except Exception as e:
|
||||||
|
with lock:
|
||||||
|
errs.append("connect: %r" % e)
|
||||||
|
return
|
||||||
|
for i in range(n):
|
||||||
|
t = time.time()
|
||||||
|
try:
|
||||||
|
obj = chat(s, "w%d-%d%s" % (idx, i, " !resident" if MODE == "resident" else ""))
|
||||||
|
except Exception as e:
|
||||||
|
with lock:
|
||||||
|
errs.append("chat: %r" % e)
|
||||||
|
return
|
||||||
|
dt = time.time() - t
|
||||||
|
with lock:
|
||||||
|
sent += 1
|
||||||
|
if obj.get("type") == "response":
|
||||||
|
done += 1
|
||||||
|
lat.append(dt)
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
def interrupter(idx, n, gap):
|
||||||
|
for i in range(n):
|
||||||
|
try:
|
||||||
|
s = connect()
|
||||||
|
s.sendall(("/interrupt stress-%d-%d\n" % (idx, i)).encode())
|
||||||
|
time.sleep(gap)
|
||||||
|
s.close()
|
||||||
|
except Exception as e:
|
||||||
|
with lock:
|
||||||
|
errs.append("intr: %r" % e)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def sampler(stop, dur):
|
||||||
|
while not stop.is_set():
|
||||||
|
try:
|
||||||
|
s = connect()
|
||||||
|
s.sendall(b"/kernel\n")
|
||||||
|
buf = b""
|
||||||
|
while b"\n" not in buf:
|
||||||
|
buf += s.recv(65536)
|
||||||
|
obj = json.loads(buf.split(b"\n")[0].decode())
|
||||||
|
sc = json.loads(obj["content"]).get("scheduler", {})
|
||||||
|
with lock:
|
||||||
|
peak["queue"] = max(peak["queue"], sc.get("ready_queue_depth", 0))
|
||||||
|
peak["pending"] = max(peak["pending"], sc.get("pending_interrupts", 0))
|
||||||
|
peak["stack"] = max(peak["stack"], sc.get("suspend_stack", 0))
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
time.sleep(0.05)
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
|
||||||
|
def kstat():
|
||||||
|
s = connect()
|
||||||
|
s.sendall(b"/kernel\n")
|
||||||
|
buf = b""
|
||||||
|
while b"\n" not in buf:
|
||||||
|
buf += s.recv(65536)
|
||||||
|
return json.loads(json.loads(buf.split(b"\n")[0].decode())["content"])
|
||||||
|
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
stop = threading.Event()
|
||||||
|
threads = [threading.Thread(target=worker, args=(i, NI)) for i in range(NC)]
|
||||||
|
threads += [threading.Thread(target=interrupter, args=(i, IE, GAP)) for i in range(IT)]
|
||||||
|
smp = threading.Thread(target=sampler, args=(stop, 0), daemon=True)
|
||||||
|
smp.start()
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join()
|
||||||
|
stop.set()
|
||||||
|
time.sleep(0.4)
|
||||||
|
st = kstat()
|
||||||
|
with lock:
|
||||||
|
lat.sort()
|
||||||
|
p = lambda q: (lat[int(len(lat) * q)] if lat else 0)
|
||||||
|
print(json.dumps({
|
||||||
|
"耗时s": round(time.time() - t0, 2),
|
||||||
|
"并发连接": NC, "每连接输入": NI, "中断线程": IT, "每线程中断": IE,
|
||||||
|
"完成": done, "错误": len(errs),
|
||||||
|
"延迟_p50": round(p(0.5), 2), "延迟_p95": round(p(0.95), 2), "延迟_max": round(lat[-1] if lat else 0, 2),
|
||||||
|
"峰值_队列": peak["queue"], "峰值_待处理中断": peak["pending"], "峰值_中断栈": peak["stack"],
|
||||||
|
"scheduler": st.get("scheduler"), "goroutines": st.get("runtime", {}).get("goroutines"),
|
||||||
|
"错误样本": errs[:3],
|
||||||
|
}, ensure_ascii=False))
|
||||||
33
third_party/homeagent-sdk/example/a2a/plugin.go
vendored
33
third_party/homeagent-sdk/example/a2a/plugin.go
vendored
@ -24,8 +24,8 @@ type Plugin struct {
|
|||||||
|
|
||||||
// 会话表:session_id → 上下文前缀。A2A 无状态协议下由插件侧维护
|
// 会话表:session_id → 上下文前缀。A2A 无状态协议下由插件侧维护
|
||||||
// 多轮上下文:同 session 的后续请求会把之前的对话拼进注入文本。
|
// 多轮上下文:同 session 的后续请求会把之前的对话拼进注入文本。
|
||||||
sessMu sync.Mutex
|
sessMu sync.Mutex
|
||||||
sessions map[string]*a2aSession
|
sessions map[string]*a2aSession
|
||||||
}
|
}
|
||||||
|
|
||||||
// a2aSession 记录一个会话的轮次历史,用于延续上下文。
|
// a2aSession 记录一个会话的轮次历史,用于延续上下文。
|
||||||
@ -47,6 +47,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
s.SetAutoRestart(true)
|
s.SetAutoRestart(true)
|
||||||
p.sdk = s
|
p.sdk = s
|
||||||
p.sessions = make(map[string]*a2aSession)
|
p.sessions = make(map[string]*a2aSession)
|
||||||
|
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
|
||||||
|
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||||
|
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
|
||||||
tp := p.name + "_"
|
tp := p.name + "_"
|
||||||
|
|
||||||
// 注册自身为输出通道:agent 回复 emit 到本通道时有落点,
|
// 注册自身为输出通道:agent 回复 emit 到本通道时有落点,
|
||||||
@ -66,7 +69,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
Key: "listen", Default: "127.0.0.1:12000",
|
Key: "listen", Default: "127.0.0.1:12000",
|
||||||
Type: "string", DisplayName: "监听地址",
|
Type: "string", DisplayName: "监听地址",
|
||||||
Description: "A2A 服务端监听地址,设为空可禁用 HTTP 服务",
|
Description: "A2A 服务端监听地址,设为空可禁用 HTTP 服务",
|
||||||
Category: p.name,
|
Category: p.name,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Outbound: query + discover
|
// Outbound: query + discover
|
||||||
@ -75,10 +78,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
|
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
|
||||||
"query": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的文本查询"},
|
"query": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的文本查询"},
|
||||||
"session_id": map[string]interface{}{"type": "string", "description": "可选。上次调用返回的 session_id,传入可延续与该 agent 的多轮对话上下文"},
|
"session_id": map[string]interface{}{"type": "string", "description": "可选。上次调用返回的 session_id,传入可延续与该 agent 的多轮对话上下文"},
|
||||||
"timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"},
|
"timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"},
|
||||||
},
|
},
|
||||||
"required": []string{"agent_url", "query"},
|
"required": []string{"agent_url", "query"},
|
||||||
},
|
},
|
||||||
@ -287,7 +290,7 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
|
|||||||
Query string `json:"query,omitempty"`
|
Query string `json:"query,omitempty"`
|
||||||
SessionID string `json:"session_id,omitempty"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
Limit int `json:"limit,omitempty"`
|
Limit int `json:"limit,omitempty"`
|
||||||
Message *struct {
|
Message *struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Parts []struct {
|
Parts []struct {
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
@ -347,7 +350,7 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
|
|||||||
if sess := p.sessions[sessionID]; sess != nil {
|
if sess := p.sessions[sessionID]; sess != nil {
|
||||||
sess.History = append(sess.History, "用户: "+queryText, "助手: "+reply)
|
sess.History = append(sess.History, "用户: "+queryText, "助手: "+reply)
|
||||||
if len(sess.History) > maxSessionTurns*2 {
|
if len(sess.History) > maxSessionTurns*2 {
|
||||||
sess.History = sess.History[len(sess.History)-maxSessionTurns*2 :]
|
sess.History = sess.History[len(sess.History)-maxSessionTurns*2:]
|
||||||
}
|
}
|
||||||
sess.LastUsed = time.Now()
|
sess.LastUsed = time.Now()
|
||||||
}
|
}
|
||||||
@ -357,11 +360,11 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
|
|||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id": req.ID,
|
"id": req.ID,
|
||||||
"result": map[string]interface{}{
|
"result": map[string]interface{}{
|
||||||
"id": fmt.Sprintf("task_%d", time.Now().UnixNano()),
|
"id": fmt.Sprintf("task_%d", time.Now().UnixNano()),
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"session_id": sessionID,
|
"session_id": sessionID,
|
||||||
"message": map[string]interface{}{
|
"message": map[string]interface{}{
|
||||||
"role": "agent",
|
"role": "agent",
|
||||||
"parts": []map[string]string{{"type": "text", "text": reply}},
|
"parts": []map[string]string{{"type": "text", "text": reply}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -457,10 +460,10 @@ type A2AResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type A2AResult struct {
|
type A2AResult struct {
|
||||||
TaskID string `json:"id,omitempty"`
|
TaskID string `json:"id,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
SessionID string `json:"session_id,omitempty"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
Message *A2AMessage `json:"message,omitempty"`
|
Message *A2AMessage `json:"message,omitempty"`
|
||||||
AgentCard *A2AAgentCard `json:"agent_card,omitempty"`
|
AgentCard *A2AAgentCard `json:"agent_card,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -47,6 +47,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
s.SetAutoRestart(true)
|
s.SetAutoRestart(true)
|
||||||
p.sdk = s
|
p.sdk = s
|
||||||
p.sessions = make(map[string]*sessionState)
|
p.sessions = make(map[string]*sessionState)
|
||||||
|
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
|
||||||
|
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||||
|
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
|
||||||
tp := p.name + "_"
|
tp := p.name + "_"
|
||||||
|
|
||||||
// 注册自身为输出通道:agent 回复 emit 到本通道时有落点。
|
// 注册自身为输出通道:agent 回复 emit 到本通道时有落点。
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"name": "browser",
|
"name": "browser",
|
||||||
"name_zh": "浏览器",
|
"name_zh": "浏览器",
|
||||||
"name_en": "Browser",
|
"name_en": "Browser",
|
||||||
"version": "2.3.0",
|
"version": "2.4.1",
|
||||||
"description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
|
"description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
|
|||||||
231
third_party/homeagent-sdk/example/browser/plugin.go
vendored
231
third_party/homeagent-sdk/example/browser/plugin.go
vendored
@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@ -45,20 +46,20 @@ type Plugin struct {
|
|||||||
// 登录态/cookies 跨 agent、跨会话、跨插件重启保留),每个 start 创建一个
|
// 登录态/cookies 跨 agent、跨会话、跨插件重启保留),每个 start 创建一个
|
||||||
// 新标签页(CDP Target)。同 source 复用自己的标签页。浏览器进程在
|
// 新标签页(CDP Target)。同 source 复用自己的标签页。浏览器进程在
|
||||||
// 最后一个标签页关闭后保留(避免反复冷启动),仅插件 Stop 时回收。
|
// 最后一个标签页关闭后保留(避免反复冷启动),仅插件 Stop 时回收。
|
||||||
sharedAllocCtx context.Context
|
sharedAllocCtx context.Context
|
||||||
sharedAllocCancel context.CancelFunc
|
sharedAllocCancel context.CancelFunc
|
||||||
sharedMu sync.Mutex
|
sharedMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
type BrowserSession struct {
|
type BrowserSession struct {
|
||||||
id string
|
id string
|
||||||
allocCtx context.Context // 共享浏览器进程上下文(shared=true 时指向全局单例)
|
allocCtx context.Context // 共享浏览器进程上下文(shared=true 时指向全局单例)
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
ctx context.Context // 本会话的 Target 上下文(一个标签页)
|
ctx context.Context // 本会话的 Target 上下文(一个标签页)
|
||||||
createdAt time.Time
|
createdAt time.Time
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
closed bool
|
closed bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
currentURL string
|
currentURL string
|
||||||
shared bool // true=共享浏览器的一个标签页;false=独占浏览器实例
|
shared bool // true=共享浏览器的一个标签页;false=独占浏览器实例
|
||||||
profileDir string // 非空表示使用持久化 profile(关闭时不删目录)
|
profileDir string // 非空表示使用持久化 profile(关闭时不删目录)
|
||||||
@ -159,6 +160,18 @@ func errResult(msg string) map[string]interface{} {
|
|||||||
return map[string]interface{}{"isError": true, "content": msg}
|
return map[string]interface{}{"isError": true, "content": msg}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseBrowserSessionTimeout(args map[string]interface{}) (time.Duration, error) {
|
||||||
|
raw := strings.TrimSpace(readArg(args, "timeout", ""))
|
||||||
|
if raw == "" {
|
||||||
|
return 0, fmt.Errorf("timeout is required;创建浏览器会话时必须明确指定关闭时长,如 15m 或 2h")
|
||||||
|
}
|
||||||
|
timeout, err := time.ParseDuration(raw)
|
||||||
|
if err != nil || timeout <= 0 {
|
||||||
|
return 0, fmt.Errorf("invalid timeout %q;请使用大于 0 的时长,如 15m 或 2h", raw)
|
||||||
|
}
|
||||||
|
return timeout, nil
|
||||||
|
}
|
||||||
|
|
||||||
func newHTTPClient(timeout int, proxyURL string) *http.Client {
|
func newHTTPClient(timeout int, proxyURL string) *http.Client {
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
DialContext: (&net.Dialer{
|
DialContext: (&net.Dialer{
|
||||||
@ -191,6 +204,9 @@ func newHTTPClient(timeout int, proxyURL string) *http.Client {
|
|||||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
p.sdk = s
|
p.sdk = s
|
||||||
s.SetAutoRestart(true)
|
s.SetAutoRestart(true)
|
||||||
|
// 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用),
|
||||||
|
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
|
||||||
|
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})
|
||||||
|
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
Key: "timeout", Default: "30", Type: "int",
|
Key: "timeout", Default: "30", Type: "int",
|
||||||
@ -276,14 +292,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
|
|
||||||
s.RegisterTool(tp+"start", sdk.ToolDef{
|
s.RegisterTool(tp+"start", sdk.ToolDef{
|
||||||
Name: tp + "start",
|
Name: tp + "start",
|
||||||
Description: "启动交互式浏览器会话。优先连接 systemd 托管的共享浏览器后端(登录态全机共享、各 agent 独立标签页);后端未安装时返回 need_install 引导(调 browser_install);无法安装时自动降级本地临时模式。同来源复用已有标签页。",
|
Description: "启动交互式浏览器会话。Agent 必须在创建时明确指定 timeout;到期后插件关闭标签页。同来源复用已有标签页时,也按本次 timeout 重新设定关闭时间。",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"},
|
"url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"},
|
||||||
"timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m,默认 10m)"},
|
"timeout": map[string]interface{}{"type": "string", "description": "必填,会话关闭前的存活时长,如 15m、2h;必须大于 0"},
|
||||||
"profile": map[string]interface{}{"type": "string", "description": "持久化档案名(可选,如 main)。同名档案共享登录态与浏览历史;不指定则为一次性临时会话"},
|
"profile": map[string]interface{}{"type": "string", "description": "持久化档案名(可选,如 main)。同名档案共享登录态与浏览历史;不指定则为一次性临时会话"},
|
||||||
},
|
},
|
||||||
|
"required": []string{"timeout"},
|
||||||
},
|
},
|
||||||
}, p.handleBrowserStart)
|
}, p.handleBrowserStart)
|
||||||
|
|
||||||
@ -471,7 +488,9 @@ type searchResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) bingSearch(query string, count int) ([]searchResult, error) {
|
func (p *Plugin) bingSearch(query string, count int) ([]searchResult, error) {
|
||||||
u := fmt.Sprintf("https://www.bing.com/search?q=%s&count=%d", url.QueryEscape(query), count)
|
// 用 cn.bing.com:www.bing.com 对程序化请求常回 302(同意/重定向页),拿不到结果块。
|
||||||
|
// 另:Bing 忽略 count 参数,翻页靠 first=,这里保留 count 只为兼容旧调用语义。
|
||||||
|
u := fmt.Sprintf("https://cn.bing.com/search?q=%s&first=1&count=%d&setlang=zh-CN", url.QueryEscape(query), count)
|
||||||
req, _ := http.NewRequest("GET", u, nil)
|
req, _ := http.NewRequest("GET", u, nil)
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
||||||
@ -481,38 +500,112 @@ func (p *Plugin) bingSearch(query string, count int) ([]searchResult, error) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
return parseBingResults(string(body), count), nil
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("Bing 返回 HTTP %d(%d 字节)", resp.StatusCode, len(body))
|
||||||
|
}
|
||||||
|
results := parseBingResults(string(body), count)
|
||||||
|
if len(results) == 0 {
|
||||||
|
// 关键:把「解析不出来」与「真的没结果」区分开。
|
||||||
|
// 以前两者都变成 "No results found.",版式一变就静默退化成「搜不到」。
|
||||||
|
return nil, fmt.Errorf("Bing 返回 %d 字节但未解析出结果(可能被反爬或版式变更,可改用 deepsearch 插件)", len(body))
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseBingResults(html string, count int) []searchResult {
|
var (
|
||||||
|
bingBlockRe = regexp.MustCompile(`<li class="b_algo"`)
|
||||||
|
bingTitleRe = regexp.MustCompile(`(?s)<h2[^>]*>\s*<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>`)
|
||||||
|
bingAnyLinkRe = regexp.MustCompile(`(?s)<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>`)
|
||||||
|
bingSnipRe = regexp.MustCompile(`(?s)<p class="b_lineclamp[^"]*"[^>]*>(.*?)</p>`)
|
||||||
|
bingCaptionRe = regexp.MustCompile(`(?s)<div class="b_caption"[^>]*>(.*?)</div>`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// splitBingBlocks 按块标记切分,每块内容延伸到下一个块标记为止。
|
||||||
|
//
|
||||||
|
// 不用 `<li class="b_algo"(?s)(.*?)</li>`:结果块内部可能嵌套 <li>(deep links),
|
||||||
|
// 非贪婪匹配会在错误位置截断;而且块内第一个 <a> 往往是 Bing 的「来源行」,
|
||||||
|
// 取到的是 `deepin.orghttps://www.deepin.org` 这种垃圾标题。
|
||||||
|
func splitBingBlocks(pageHTML string) []string {
|
||||||
|
locs := bingBlockRe.FindAllStringIndex(pageHTML, -1)
|
||||||
|
if len(locs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
blocks := make([]string, 0, len(locs))
|
||||||
|
for i, loc := range locs {
|
||||||
|
end := len(pageHTML)
|
||||||
|
if i+1 < len(locs) {
|
||||||
|
end = locs[i+1][0]
|
||||||
|
}
|
||||||
|
blocks = append(blocks, pageHTML[loc[1]:end])
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBingResults(pageHTML string, count int) []searchResult {
|
||||||
|
if count <= 0 {
|
||||||
|
count = 5
|
||||||
|
}
|
||||||
var results []searchResult
|
var results []searchResult
|
||||||
re := regexp.MustCompile(`<li class="b_algo"(?s)(.*?)</li>`)
|
for _, block := range splitBingBlocks(pageHTML) {
|
||||||
matches := re.FindAllStringSubmatch(html, -1)
|
|
||||||
for _, m := range matches {
|
|
||||||
if len(results) >= count {
|
if len(results) >= count {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
block := m[1]
|
// 标题:现代 Bing 是 <h2><a href=...>标题</a></h2>;没有 h2 时才退回到块内第一个链接。
|
||||||
var r searchResult
|
var href, title string
|
||||||
hrefRe := regexp.MustCompile(`<a[^>]+href="([^"]+)"[^>]*>`)
|
if m := bingTitleRe.FindStringSubmatch(block); m != nil {
|
||||||
if hm := hrefRe.FindStringSubmatch(block); len(hm) > 1 {
|
href, title = m[1], html.UnescapeString(stripTags(m[2]))
|
||||||
r.URL = hm[1]
|
} else if m := bingAnyLinkRe.FindStringSubmatch(block); m != nil {
|
||||||
|
href, title = m[1], html.UnescapeString(stripTags(m[2]))
|
||||||
}
|
}
|
||||||
titleRe := regexp.MustCompile(`<a[^>]+href="[^"]+"[^>]*>(.*?)</a>`)
|
href = bingRealURL(html.UnescapeString(href))
|
||||||
if tm := titleRe.FindStringSubmatch(block); len(tm) > 1 {
|
|
||||||
r.Title = stripTags(tm[1])
|
// 摘要:新版在 p.b_lineclamp*,旧版在 div.b_caption > p
|
||||||
|
var snippet string
|
||||||
|
if m := bingSnipRe.FindStringSubmatch(block); m != nil {
|
||||||
|
snippet = html.UnescapeString(stripTags(m[1]))
|
||||||
|
} else if m := bingCaptionRe.FindStringSubmatch(block); m != nil {
|
||||||
|
snippet = html.UnescapeString(stripTags(m[1]))
|
||||||
}
|
}
|
||||||
snipRe := regexp.MustCompile(`<div class="b_caption">.*?<p>(.*?)</p>`)
|
|
||||||
if sm := snipRe.FindStringSubmatch(block); len(sm) > 1 {
|
title, snippet = strings.TrimSpace(title), strings.TrimSpace(snippet)
|
||||||
r.Snippet = stripTags(sm[1])
|
if href == "" || title == "" || !strings.HasPrefix(href, "http") {
|
||||||
}
|
continue
|
||||||
if r.URL != "" && r.Title != "" {
|
|
||||||
results = append(results, r)
|
|
||||||
}
|
}
|
||||||
|
results = append(results, searchResult{Title: title, URL: href, Snippet: snippet})
|
||||||
}
|
}
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bingRealURL 解开 Bing 的跳转包装:/ck/a?...&u=a1<base64url>&... → 真实 URL。
|
||||||
|
// 不解的话模型拿到的是 `https://cn.bing.com/ck/a?...` 这种不可读地址。
|
||||||
|
func bingRealURL(href string) string {
|
||||||
|
href = strings.TrimSpace(href)
|
||||||
|
if href == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.Contains(href, "/ck/a") && !strings.Contains(href, "u=a1") {
|
||||||
|
return href
|
||||||
|
}
|
||||||
|
u, err := url.Parse(href)
|
||||||
|
if err != nil {
|
||||||
|
return href
|
||||||
|
}
|
||||||
|
raw := u.Query().Get("u")
|
||||||
|
if !strings.HasPrefix(raw, "a1") {
|
||||||
|
return href
|
||||||
|
}
|
||||||
|
b64 := raw[2:]
|
||||||
|
for _, enc := range []*base64.Encoding{base64.RawURLEncoding, base64.URLEncoding, base64.RawStdEncoding} {
|
||||||
|
if dec, err := enc.DecodeString(b64); err == nil {
|
||||||
|
s := string(dec)
|
||||||
|
if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return href
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) {
|
func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) {
|
||||||
query := readArg(args, "query", "")
|
query := readArg(args, "query", "")
|
||||||
if query == "" {
|
if query == "" {
|
||||||
@ -881,10 +974,9 @@ func (p *Plugin) localSpawnFailback() (context.Context, context.CancelFunc, cont
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
|
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
|
||||||
timeoutStr := readArg(args, "timeout", "10m")
|
timeout, err := parseBrowserSessionTimeout(args)
|
||||||
timeout, err := time.ParseDuration(timeoutStr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
timeout = 10 * time.Minute
|
return errResult(err.Error()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
source := readArg(args, "source", "")
|
source := readArg(args, "source", "")
|
||||||
@ -899,13 +991,19 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
id := s.id
|
id := s.id
|
||||||
cur := s.currentURL
|
cur := s.currentURL
|
||||||
|
s.createdAt = time.Now()
|
||||||
|
s.timeout = timeout
|
||||||
|
closesAt := s.createdAt.Add(timeout)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
log.Printf("[%s] reused browser session %s: timeout=%v closes_at=%s source=%s", p.name, id, timeout, closesAt.Format(time.RFC3339), source)
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"id": id,
|
"id": id,
|
||||||
"status": "reused",
|
"status": "reused",
|
||||||
"url": cur,
|
"url": cur,
|
||||||
"note": "已复用本来源的现有标签页(登录态全机共享)",
|
"timeout": timeout.String(),
|
||||||
|
"closes_at": closesAt.Format(time.RFC3339),
|
||||||
|
"note": "已复用本来源的现有标签页,并按本次 timeout 重新设定关闭时间",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -942,7 +1040,7 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
|||||||
"插件会注册 homeagent-browser.service 并启动。" +
|
"插件会注册 homeagent-browser.service 并启动。" +
|
||||||
"若本机无法联网安装 chromium,可继续用本地临时模式(重试 browser_start 即自动降级)。"
|
"若本机无法联网安装 chromium,可继续用本地临时模式(重试 browser_start 即自动降级)。"
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"error": "backend not installed",
|
"error": "backend not installed",
|
||||||
"need_install": true,
|
"need_install": true,
|
||||||
"guide": guide,
|
"guide": guide,
|
||||||
}, nil
|
}, nil
|
||||||
@ -972,13 +1070,15 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
|||||||
session.currentURL = initURL
|
session.currentURL = initURL
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[%s] created browser session %s: url=%s timeout=%v source=%s", p.name, id, initURL, timeout, source)
|
closesAt := session.createdAt.Add(timeout)
|
||||||
|
log.Printf("[%s] created browser session %s: url=%s timeout=%v closes_at=%s source=%s", p.name, id, initURL, timeout, closesAt.Format(time.RFC3339), source)
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"id": id,
|
"id": id,
|
||||||
"status": "created",
|
"status": "created",
|
||||||
"mode": "shared-backend",
|
"mode": "shared-backend",
|
||||||
"url": initURL,
|
"url": initURL,
|
||||||
"timeout": timeout.String(),
|
"timeout": timeout.String(),
|
||||||
|
"closes_at": closesAt.Format(time.RFC3339),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1044,11 +1144,11 @@ func (p *Plugin) handleScreenshot(args map[string]interface{}) (interface{}, err
|
|||||||
}
|
}
|
||||||
b64 := base64.StdEncoding.EncodeToString(buf)
|
b64 := base64.StdEncoding.EncodeToString(buf)
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"format": format,
|
"format": format,
|
||||||
"size": len(buf),
|
"size": len(buf),
|
||||||
"base64": b64,
|
"base64": b64,
|
||||||
"data_uri": fmt.Sprintf("data:image/png;base64,%s", b64),
|
"data_uri": fmt.Sprintf("data:image/png;base64,%s", b64),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1079,11 +1179,11 @@ func (p *Plugin) handleHTML(args map[string]interface{}) (interface{}, error) {
|
|||||||
html = html[:maxChars] + "\n\n[HTML truncated]"
|
html = html[:maxChars] + "\n\n[HTML truncated]"
|
||||||
}
|
}
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"title": title,
|
"title": title,
|
||||||
"url": currentURL,
|
"url": currentURL,
|
||||||
"html": html,
|
"html": html,
|
||||||
"length": len(html),
|
"length": len(html),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1204,13 +1304,20 @@ func (p *Plugin) cleanupLoop() {
|
|||||||
case <-p.stopCh:
|
case <-p.stopCh:
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
|
now := time.Now()
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
for id, s := range p.sessions {
|
for id, s := range p.sessions {
|
||||||
if time.Since(s.createdAt) >= s.timeout {
|
s.mu.Lock()
|
||||||
log.Printf("[%s] cleanup: browser session %s expired", p.name, id)
|
closesAt := s.createdAt.Add(s.timeout)
|
||||||
delete(p.sessions, id)
|
expired := !now.Before(closesAt)
|
||||||
s.Close()
|
s.mu.Unlock()
|
||||||
p.sdk.InjectInterruptText(p.name, p.name, fmt.Sprintf("[浏览器会话 %s 已超时关闭]", id))
|
if expired {
|
||||||
|
log.Printf("[%s] cleanup: browser session %s reached agent-specified close time %s", p.name, id, closesAt.Format(time.RFC3339))
|
||||||
|
delete(p.sessions, id)
|
||||||
|
s.Close()
|
||||||
|
// NoMemory:会话生命周期通知,不是记忆内容。
|
||||||
|
p.sdk.InjectInterruptTextOpts(p.name, p.name,
|
||||||
|
fmt.Sprintf("[浏览器会话 %s 已按指定时间关闭]", id), sdk.InjectOptions{NoMemory: true})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
@ -1310,7 +1417,7 @@ WantedBy=multi-user.target
|
|||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"status": "installed",
|
"status": "installed",
|
||||||
"endpoint": cdpEndpoint,
|
"endpoint": cdpEndpoint,
|
||||||
"chrome": chromePath,
|
"chrome": chromePath,
|
||||||
"profile": profileDir,
|
"profile": profileDir,
|
||||||
"guide": guide,
|
"guide": guide,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user