@lienze/dsh-subagent-model-policy 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/LICENSE +21 -0
- package/README.md +81 -2
- package/README_en.md +82 -0
- package/client.js +1271 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +1 -0
- package/index.js +810 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/management.js +673 -0
- package/package.json +74 -4
- package/policy.js +119 -0
package/index.js
ADDED
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { defaults, validatePolicy, renderContext, selectRoute, checkAdmission, sameRoute, routeKey, fail, executionEvidence } from './policy.js';
|
|
4
|
+
import {
|
|
5
|
+
readLibrary, readDefault, legacyLibrary, materializeLegacy, emptyLibrary, buildPolicy,
|
|
6
|
+
snapshotForStrategy, sameSnapshotContent, sameStrategyContent, readStrategyDraft,
|
|
7
|
+
readPoolEntries, validateStrategyRefs, assertStrategyNameFree, validateForUse,
|
|
8
|
+
validatePoolSave, planPoolSync, defaultFromStrategy, importDefault, strategyNeedsAdjust,
|
|
9
|
+
limitsConflict, readRoutes, LEGACY_STRATEGY_ID, LEGACY_INHERIT_ID,
|
|
10
|
+
assertHostActive, PLUGIN_MAX_ACTIVE, refreshSnapshotInfo,
|
|
11
|
+
} from './management.js';
|
|
12
|
+
|
|
13
|
+
export const name = 'lez-subagent-policy';
|
|
14
|
+
export const inject = ['agents','sessions','commands','tools','systemPrompt','subagents','subagentModelSelection','sessionProjections','storageDomain','llm'];
|
|
15
|
+
const blob = z.unknown();
|
|
16
|
+
const storedPolicy = z.object({
|
|
17
|
+
revision: z.number().int().nonnegative(),
|
|
18
|
+
policy: z.object({ enabled: z.boolean() }).passthrough(),
|
|
19
|
+
library: z.unknown().optional(),
|
|
20
|
+
}).passthrough();
|
|
21
|
+
const storedGlobal = z.object({ revision: z.number().int().nonnegative(), default: z.unknown().nullable() });
|
|
22
|
+
const spec = {
|
|
23
|
+
name: 'lez_subagent_policy',
|
|
24
|
+
version: 1,
|
|
25
|
+
layout: 'per-record',
|
|
26
|
+
// Adding `global` and the optional `library` field does not change the stored
|
|
27
|
+
// version: legacy records still parse and are read as v1 records.
|
|
28
|
+
global: { schema: storedGlobal, initial: { revision: 0, default: null } },
|
|
29
|
+
tables: { policies: { valueSchema: storedPolicy }, runs: { valueSchema: blob } },
|
|
30
|
+
};
|
|
31
|
+
const evidenceDefinition = {
|
|
32
|
+
key:'lezSubagentEvidence',stateVersion:1,stateSchema:z.object({requested:blob.optional(),actual:blob.optional(),requestSeq:z.number().optional(),responseSeq:z.number().optional(),floor:z.number()}),
|
|
33
|
+
init:(_header,inherited)=>({floor:inherited}),
|
|
34
|
+
apply:(s,e)=>executionEvidence(s,e,s.floor),
|
|
35
|
+
};
|
|
36
|
+
const clone = v => structuredClone(v);
|
|
37
|
+
const render = (_args,value)=>[{type:'text',text:JSON.stringify(value,null,2)}];
|
|
38
|
+
const COVERAGE = [
|
|
39
|
+
{entry:'delegate_with_policy',status:'managed-local',detail:'本插件工具:模型参数选择、深度、并发子智能体数量、路由预检;仅本地 spawn/fork。'},
|
|
40
|
+
...['subagent','subagent_fork','workflow','spawn_teammate'].map(entry=>({entry,status:'local-admission',detail:'仅本地 Agent 创建及出站路由校验;不改写原生参数。入口未能指定目标模型时会拒绝,请改用策略工具。'})),
|
|
41
|
+
{entry:'external backends',status:'not-covered',detail:'外部执行及其他插件直接调用模型不在本地 Agent 准入范围。'},
|
|
42
|
+
];
|
|
43
|
+
const ACTIONS = new Set([
|
|
44
|
+
'read','save','reset',
|
|
45
|
+
'pool.save','pool.note','pool.sync','limits.save',
|
|
46
|
+
'strategy.save','strategy.enable','strategy.delete','strategy.close',
|
|
47
|
+
'strategy.default','default.clear',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
export async function apply(ctx) {
|
|
51
|
+
const domain = await ctx.storageDomain.open(spec);
|
|
52
|
+
ctx.effect(()=>()=>domain.close());
|
|
53
|
+
const policies = domain.table('policies'), runs = domain.table('runs');
|
|
54
|
+
const leases = new Map(), residents = new Map(), pending = new Map();
|
|
55
|
+
let closed = false, writes = Promise.resolve();
|
|
56
|
+
ctx.effect(()=>()=>{closed=true; leases.clear(); residents.clear(); pending.clear();});
|
|
57
|
+
ctx.sessionProjections.register(evidenceDefinition);
|
|
58
|
+
const session = agent => ctx.sessions.get(agent.id);
|
|
59
|
+
// The Host exposes its depth ceiling directly. Its live-child capacity is a
|
|
60
|
+
// public, volatile settings value: `settings.describe()` projects the active
|
|
61
|
+
// plugin instance's resolved Config, so `value.maxActiveSubagents` is the
|
|
62
|
+
// effective limit. The service is optional and must never be a hard inject.
|
|
63
|
+
const SETTINGS_NS = 'subagent';
|
|
64
|
+
const hostDepth = () => Math.min(32, ctx.subagents.resolveMaxDepth() ?? 32);
|
|
65
|
+
const unknownCapacity = reason => ({ maxActive: null, maxActiveKnown: false, maxActiveReason: reason });
|
|
66
|
+
const reasonOf = error => String(error?.message ?? error);
|
|
67
|
+
function readHostCapacity() {
|
|
68
|
+
if (typeof ctx.get !== 'function') return unknownCapacity('当前上下文不支持可选服务读取');
|
|
69
|
+
let settings;
|
|
70
|
+
try { settings = ctx.get('settings'); }
|
|
71
|
+
catch (error) { return unknownCapacity(`settings 服务读取失败:${reasonOf(error)}`); }
|
|
72
|
+
if (!settings || typeof settings.describe !== 'function') return unknownCapacity('settings 服务未挂载');
|
|
73
|
+
let descriptors;
|
|
74
|
+
try { descriptors = settings.describe({ redactSecrets: true }); }
|
|
75
|
+
catch (error) { return unknownCapacity(`settings.describe 调用失败:${reasonOf(error)}`); }
|
|
76
|
+
if (!Array.isArray(descriptors)) return unknownCapacity('settings.describe 未返回描述数组');
|
|
77
|
+
// Never guess across namespaces: another plugin may expose a same-named field.
|
|
78
|
+
const matches = descriptors.filter(item => item && typeof item === 'object' && item.ns === SETTINGS_NS && item.applies === 'live');
|
|
79
|
+
if (!matches.length) return unknownCapacity(`未找到 ns=${SETTINGS_NS} 且 applies=live 的配置描述`);
|
|
80
|
+
if (matches.length > 1) return unknownCapacity(`ns=${SETTINGS_NS} 且 applies=live 的配置描述不唯一(${matches.length} 条)`);
|
|
81
|
+
const active = matches[0].value?.maxActiveSubagents;
|
|
82
|
+
if (!Number.isSafeInteger(active) || active < 1) return unknownCapacity('value.maxActiveSubagents 不是正安全整数');
|
|
83
|
+
return {
|
|
84
|
+
maxActive: active, maxActiveKnown: true, namespace: SETTINGS_NS,
|
|
85
|
+
...(Number.isSafeInteger(matches[0].revision) ? { revision: matches[0].revision } : {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const hostCeilings = () => {
|
|
89
|
+
const capacity = readHostCapacity();
|
|
90
|
+
return {
|
|
91
|
+
maxDepth: hostDepth(),
|
|
92
|
+
maxActive: capacity.maxActive,
|
|
93
|
+
maxActiveKnown: capacity.maxActiveKnown,
|
|
94
|
+
...(capacity.maxActiveKnown
|
|
95
|
+
? { namespace: capacity.namespace, ...(capacity.revision === undefined ? {} : { revision: capacity.revision }) }
|
|
96
|
+
: { maxActiveReason: capacity.maxActiveReason }),
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
const globalState = () => {
|
|
100
|
+
const value = domain.global.get();
|
|
101
|
+
return value && typeof value === 'object' ? value : { revision: 0, default: null };
|
|
102
|
+
};
|
|
103
|
+
function rootId(agent) {
|
|
104
|
+
let s = session(agent), id = agent.id;
|
|
105
|
+
const seen = new Set();
|
|
106
|
+
while(s?.header.origin === 'subagent' && s.header.parentSession) {
|
|
107
|
+
if(seen.has(id)) fail('会话谱系循环');
|
|
108
|
+
seen.add(id);
|
|
109
|
+
const audit = runs.get(id);
|
|
110
|
+
if(audit?.rootId) return audit.rootId;
|
|
111
|
+
id=s.header.parentSession; s=ctx.sessions.get(id);
|
|
112
|
+
}
|
|
113
|
+
return id;
|
|
114
|
+
}
|
|
115
|
+
function frozenSnapshot(s) {
|
|
116
|
+
if(!s) return undefined;
|
|
117
|
+
const value=ctx.sessionProjections.stateOf(s,'subagentModelSelectionPolicy');
|
|
118
|
+
return Array.isArray(value) ? value : undefined;
|
|
119
|
+
}
|
|
120
|
+
// The host freezes its global allow-list into each brand-new top-level session. That
|
|
121
|
+
// snapshot may live on this agent's Session or, for children created by this plugin,
|
|
122
|
+
// only on the root. A non-fork local root without a snapshot falls back to the host's
|
|
123
|
+
// current enabled list; the host list remains the upper bound, never this plugin's own.
|
|
124
|
+
function authorization(agent, current=ctx.subagentModelSelection.current()) {
|
|
125
|
+
if(!current.enabled) return {source:'none', routes:[], frozen:null};
|
|
126
|
+
const own=session(agent), root=ctx.sessions.get(rootId(agent));
|
|
127
|
+
const frozen=frozenSnapshot(own) ?? frozenSnapshot(root);
|
|
128
|
+
const allowed=new Set(current.allowedModels.map(routeKey));
|
|
129
|
+
if(frozen) return {source:'session', frozen:frozen.length, routes:frozen.filter(r=>allowed.has(routeKey(r))).slice(0,24)};
|
|
130
|
+
const header=root?.header;
|
|
131
|
+
if(header && header.origin!=='subagent' && header.isSeeded!==true) return {source:'host', frozen:null, routes:current.allowedModels.map(r=>({...r})).slice(0,24)};
|
|
132
|
+
return {source:'none', frozen:null, routes:[]};
|
|
133
|
+
}
|
|
134
|
+
function officialRoutes(agent, current) { return authorization(agent, current).routes; }
|
|
135
|
+
function baseline(agent) {
|
|
136
|
+
const h=session(agent)?.requestHeader()?.config;
|
|
137
|
+
return h ? {provider:h.provider,model:h.model} : {provider:agent.options?.provider,model:agent.options?.model};
|
|
138
|
+
}
|
|
139
|
+
function emptyLegacyView(policy) {
|
|
140
|
+
return {version:1, strategies:[], pool:[], limits:{maxDepth:policy.maxDepth,maxActive:policy.maxActive}, active:null, initialized:null};
|
|
141
|
+
}
|
|
142
|
+
function viewLibrary(record) {
|
|
143
|
+
if(record.library) return record.library;
|
|
144
|
+
if(record.absent) return {version:1, strategies:[], pool:[], limits:null, active:null, initialized:null};
|
|
145
|
+
const p=record.policy;
|
|
146
|
+
if(!p?.enabled && !(p.models||[]).length && p.mode==='inherit') return emptyLegacyView(p);
|
|
147
|
+
return legacyLibrary(p);
|
|
148
|
+
}
|
|
149
|
+
// Single record for both the effective policy and the library: one put writes
|
|
150
|
+
// both, so the active policy can never be saved while the library update fails.
|
|
151
|
+
function recordView(id, agent) {
|
|
152
|
+
const stored=policies.get(id);
|
|
153
|
+
if(!stored) return {revision:0, policy:defaults(hostDepth(),officialRoutes(agent)), library:null, explicit:false, absent:true};
|
|
154
|
+
if(stored.library!==undefined) {
|
|
155
|
+
try {
|
|
156
|
+
const library=readLibrary(stored.library);
|
|
157
|
+
return {revision:stored.revision, policy:buildPolicy(library,hostDepth()), library, explicit:true};
|
|
158
|
+
} catch(error) {
|
|
159
|
+
return {revision:stored.revision, policy:stored.policy, library:null, explicit:false, issue:`已保存的策略库无法读取,暂按旧版策略展示:${String(error.message ?? error)}`};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return {revision:stored.revision, policy:stored.policy, library:null, explicit:false};
|
|
163
|
+
}
|
|
164
|
+
// This is the policy for FUTURE delegations, not an existing child's own LLM binding.
|
|
165
|
+
function recordFor(agent) { return recordView(rootId(agent), agent); }
|
|
166
|
+
function activeMeta(record) {
|
|
167
|
+
if(record.library?.active) {
|
|
168
|
+
const snap=record.library.active.snapshot;
|
|
169
|
+
return {strategyId:record.library.active.strategyId, strategyName:snap.name, strategyVersion:snap.version, strategyType:snap.type};
|
|
170
|
+
}
|
|
171
|
+
if(!record.library && record.policy?.enabled) {
|
|
172
|
+
return {strategyId:'legacy', strategyName:record.policy.mode==='inherit'?'旧版跟随策略':'旧版会话策略', strategyVersion:record.revision, strategyType:record.policy.mode};
|
|
173
|
+
}
|
|
174
|
+
return {strategyId:'', strategyName:'', strategyVersion:record.revision, strategyType:''};
|
|
175
|
+
}
|
|
176
|
+
function preview(agent,record=recordFor(agent),auth=authorization(agent)) {
|
|
177
|
+
return renderContext(record,{depth:session(agent)?.header.delegationDepth ?? 0,ceiling:hostDepth(),authorized:auth.routes,authorizationSource:auth.source});
|
|
178
|
+
}
|
|
179
|
+
ctx.systemPrompt.context({name:'lez:subagent-policy',order:850,interpolate:false,text:({agent})=>agent ? preview(agent):''});
|
|
180
|
+
|
|
181
|
+
function readDefaultSafe(global) {
|
|
182
|
+
if(!global?.default) return {value:null, issue:null};
|
|
183
|
+
try { return {value:readDefault(global), issue:null}; }
|
|
184
|
+
catch(error) { return {value:null, issue:`新会话默认内容无法读取:${String(error.message ?? error)}`}; }
|
|
185
|
+
}
|
|
186
|
+
// Thinking levels are Host metadata: never hardcode names or an implicit
|
|
187
|
+
// fallback order. Missing or malformed metadata becomes an explicit empty set.
|
|
188
|
+
function reasoningOf(info) {
|
|
189
|
+
const raw=info?.reasoning, efforts=[];
|
|
190
|
+
if(Array.isArray(raw?.efforts)) for(const item of raw.efforts.slice(0,24)) {
|
|
191
|
+
const id=typeof item?.id==='string'?item.id.trim():'';
|
|
192
|
+
if(!id || id.length>80) continue;
|
|
193
|
+
const name=typeof item?.name==='string'&&item.name.trim()?item.name.trim().slice(0,80):id;
|
|
194
|
+
if(efforts.some(row=>row.id===id)) continue;
|
|
195
|
+
efforts.push({id,name});
|
|
196
|
+
}
|
|
197
|
+
const wanted=typeof raw?.defaultEffort==='string'?raw.defaultEffort.trim():'';
|
|
198
|
+
return {efforts,defaultEffort:efforts.some(row=>row.id===wanted)?wanted:''};
|
|
199
|
+
}
|
|
200
|
+
const noReasoning=()=>({efforts:[],defaultEffort:''});
|
|
201
|
+
// Resolve display names/availability for every route this session knows about.
|
|
202
|
+
async function modelCatalog(agent, library, authz, signal) {
|
|
203
|
+
const routes=new Map();
|
|
204
|
+
const add=list=>{for(const route of list||[]) if(route?.provider && route?.model) routes.set(routeKey(route),{provider:route.provider,model:route.model});};
|
|
205
|
+
add(authz.routes); add(library?.pool); add(library?.strategies?.flatMap(s=>s.models)); add(library?.active?.snapshot?.models);
|
|
206
|
+
const allowed=new Set(authz.routes.map(routeKey));
|
|
207
|
+
const registered=new Set(ctx.llm.listProviders().map(p=>p.id));
|
|
208
|
+
await Promise.all([...routes.values()].map(async route=>{
|
|
209
|
+
const key=routeKey(route); const row={...route,name:route.model,description:'',authorized:allowed.has(key),available:false,reasoning:noReasoning()};
|
|
210
|
+
try {
|
|
211
|
+
if(!registered.has(route.provider)) throw new Error('unregistered');
|
|
212
|
+
const info=await ctx.llm.resolveModelInfo(route.provider,route.model,signal);
|
|
213
|
+
row.name=info?.name || route.model; row.description=info?.description ?? ''; row.available=true;
|
|
214
|
+
row.reasoning=reasoningOf(info);
|
|
215
|
+
} catch { row.available=false; }
|
|
216
|
+
routes.set(key,row);
|
|
217
|
+
}));
|
|
218
|
+
return routes;
|
|
219
|
+
}
|
|
220
|
+
async function availabilityOf(routes, signal) {
|
|
221
|
+
const available=new Set();
|
|
222
|
+
const registered=new Set(ctx.llm.listProviders().map(p=>p.id));
|
|
223
|
+
await Promise.all(routes.map(async route=>{
|
|
224
|
+
if(!registered.has(route.provider)) return;
|
|
225
|
+
try { await ctx.llm.resolveModelInfo(route.provider,route.model,signal); available.add({provider:route.provider,model:route.model}); } catch { /* unavailable */ }
|
|
226
|
+
}));
|
|
227
|
+
return available;
|
|
228
|
+
}
|
|
229
|
+
function buildSnapshotFor(library, strategy, catalog, now) {
|
|
230
|
+
const pool=library.pool.map(entry=>{
|
|
231
|
+
const row=catalog.get(routeKey(entry));
|
|
232
|
+
return {...entry,name:row?.name || entry.model};
|
|
233
|
+
});
|
|
234
|
+
return snapshotForStrategy(strategy,pool,{now,limits:null});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Read actual responses, never child self-identification. Projection skips fork seed.
|
|
238
|
+
async function auditView(row) {
|
|
239
|
+
const live=ctx.sessions.get(row.id);
|
|
240
|
+
let evidence=live ? ctx.sessionProjections.stateOf(live,'lezSubagentEvidence'):undefined;
|
|
241
|
+
if(!evidence) evidence=row.evidenceSnapshot;
|
|
242
|
+
const result={...row};
|
|
243
|
+
delete result.policy; delete result.evidenceSnapshot;
|
|
244
|
+
if(evidence?.actual) {
|
|
245
|
+
result.actualProvider=evidence.actual.provider; result.actualModel=evidence.actual.model;
|
|
246
|
+
result.evidence='assistant/message';
|
|
247
|
+
} else if(evidence?.requested) {
|
|
248
|
+
result.actualProvider=''; result.actualModel=''; result.evidence='request/header (尚无响应)';
|
|
249
|
+
} else { result.actualProvider='';result.actualModel='';result.evidence='pending'; }
|
|
250
|
+
return result;
|
|
251
|
+
}
|
|
252
|
+
async function snapshot(agent) {
|
|
253
|
+
const id=rootId(agent), record=recordFor(agent), current=ctx.subagentModelSelection.current(), authz=authorization(agent,current);
|
|
254
|
+
const library=viewLibrary(record);
|
|
255
|
+
const catalog=await modelCatalog(agent,library,authz);
|
|
256
|
+
const global=globalState();
|
|
257
|
+
const def=readDefaultSafe(global);
|
|
258
|
+
const ceilings=hostCeilings();
|
|
259
|
+
const active=library.active ? {strategyId:library.active.strategyId,appliedAt:library.active.appliedAt,snapshot:clone(library.active.snapshot)} : null;
|
|
260
|
+
const strategies=library.strategies.map(strategy=>({
|
|
261
|
+
...clone(strategy),
|
|
262
|
+
needsAdjust:strategyNeedsAdjust(strategy,library.pool)
|
|
263
|
+
|| (strategy.models||[]).some(route=>{const row=catalog.get(routeKey(route));return !row?.authorized || !row?.available;}),
|
|
264
|
+
}));
|
|
265
|
+
const pool=library.pool.map(entry=>{
|
|
266
|
+
const row=catalog.get(routeKey(entry));
|
|
267
|
+
return {...clone(entry), name:row?.name || entry.model, authorized:!!row?.authorized, available:!!row?.available,
|
|
268
|
+
reasoning:row?.reasoning?clone(row.reasoning):noReasoning()};
|
|
269
|
+
});
|
|
270
|
+
const defaultSetting={
|
|
271
|
+
revision:global.revision,
|
|
272
|
+
snapshot:def.value ? clone(def.value) : null,
|
|
273
|
+
conflict:def.value ? limitsConflict(def.value.limits,{...ceilings,activeKnown:ceilings.maxActiveKnown}) : null,
|
|
274
|
+
hostKnown:ceilings.maxActiveKnown,
|
|
275
|
+
};
|
|
276
|
+
const rows=[...runs.entries()].map(([,r])=>r).filter(r=>r.rootId===id).sort((a,b)=>b.createdAt-a.createdAt).slice(0,50);
|
|
277
|
+
const diagnostics=[];
|
|
278
|
+
if(authz.source==='host') diagnostics.push('本会话没有官方固化快照;固定/自选按宿主当前已启用路由临时生效,原生 subagent 仍不带模型参数,请使用 delegate_with_policy。');
|
|
279
|
+
else if(!authz.routes.length) diagnostics.push(current.enabled
|
|
280
|
+
? '当前会话没有官方显式模型选择授权快照,且该会话不满足兜底条件(例如 fork/种子会话);可使用跟随模式。'
|
|
281
|
+
: '官方子智能体模型选择未启用;当前只能使用跟随模式。');
|
|
282
|
+
if(record.issue) diagnostics.push(record.issue);
|
|
283
|
+
if(def.issue) diagnostics.push(def.issue);
|
|
284
|
+
const libraryConflict=limitsConflict(library.limits,{...ceilings,activeKnown:ceilings.maxActiveKnown});
|
|
285
|
+
if(libraryConflict?.depthOver) diagnostics.push(`已保存的本会话限制超出宿主深度上限 ${ceilings.maxDepth};新的启用/保存生效会被拒绝,已启用内容不会自动停用。`);
|
|
286
|
+
if(libraryConflict?.activeOver) diagnostics.push(`已保存的本会话限制超出宿主并发子智能体数量 ${Math.min(PLUGIN_MAX_ACTIVE,ceilings.maxActive)};新的启用/保存生效会被拒绝,已启用内容不会自动停用。`);
|
|
287
|
+
if(!ceilings.maxActiveKnown) diagnostics.push(`无法确认宿主并发子智能体数量(${ceilings.maxActiveReason});新的限制保存与启用/保存生效会被拒绝,已启用内容继续运行。`);
|
|
288
|
+
return {sessionId:agent.id,rootId:id,inherited:id!==agent.id,revision:record.revision,canEdit:id===agent.id,
|
|
289
|
+
policy:clone(record.policy), candidates:[...catalog.values()].map(row=>clone(row)),ceilings:{maxDepth:ceilings.maxDepth},
|
|
290
|
+
host:ceilings, preview:preview(agent,record,authz),coverage:COVERAGE,
|
|
291
|
+
authorization:{hostEnabled:current.enabled,hostAllowed:current.enabled ? current.allowedModels.length : 0,
|
|
292
|
+
sessionFrozen:authz.frozen, source:authz.source},
|
|
293
|
+
management:{explicit:!!record.explicit, strategies, pool, limits:library.limits?clone(library.limits):null,
|
|
294
|
+
active:active?clone(active):null, initialized:library.initialized?clone(library.initialized):null},
|
|
295
|
+
defaultSetting,
|
|
296
|
+
runs:await Promise.all(rows.map(auditView)),diagnostics};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const enqueue=fn=>{const result=writes.then(fn);writes=result.catch(()=>{});return result;};
|
|
300
|
+
function ensureRoot(agent) { if(rootId(agent)!==agent.id) fail('子会话仅继承根会话策略,不可扩大授权'); }
|
|
301
|
+
function expectedRevision(request) {
|
|
302
|
+
if(!Number.isSafeInteger(request.expectedRevision) || request.expectedRevision<0) fail('expectedRevision 无效');
|
|
303
|
+
return request.expectedRevision;
|
|
304
|
+
}
|
|
305
|
+
function rejectUnknown(request, allowed) {
|
|
306
|
+
for(const key of Object.keys(request)) if(!allowed.includes(key)) fail(`请求存在未知字段:${key}`);
|
|
307
|
+
}
|
|
308
|
+
async function mutate(agent, request, operation, signal) {
|
|
309
|
+
ensureRoot(agent);
|
|
310
|
+
return enqueue(async()=>{
|
|
311
|
+
if(closed) fail('插件已卸载');
|
|
312
|
+
signal?.throwIfAborted();
|
|
313
|
+
const stored=policies.get(agent.id);
|
|
314
|
+
const revision=stored?.revision ?? 0;
|
|
315
|
+
if(expectedRevision(request)!==revision) fail('版本冲突:策略已在其他位置更新,请刷新后重试');
|
|
316
|
+
const record=recordView(agent.id,agent);
|
|
317
|
+
if(record.issue) fail(record.issue);
|
|
318
|
+
const library=record.explicit ? readLibrary(record.library) : record.absent ? emptyLibrary({now:Date.now()}) : materializeLegacy(record.policy);
|
|
319
|
+
const outcome=await operation({record,revision,library,signal});
|
|
320
|
+
signal?.throwIfAborted();
|
|
321
|
+
if(closed) fail('插件已卸载');
|
|
322
|
+
if(outcome === false) return snapshot(agent);
|
|
323
|
+
const nextLibrary=outcome.library ?? library;
|
|
324
|
+
const nextPolicy=outcome.policy ?? buildPolicy(nextLibrary,hostDepth());
|
|
325
|
+
await policies.put(agent.id,{revision:revision+1,policy:nextPolicy,library:nextLibrary});
|
|
326
|
+
return snapshot(agent);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
async function preflight(policy, signal) {
|
|
330
|
+
if(policy.enabled && policy.mode!=='inherit') for(const route of policy.models) await ctx.llm.resolveModelInfo(route.provider,route.model,signal);
|
|
331
|
+
}
|
|
332
|
+
function hostAuth(agent) { return authorization(agent); }
|
|
333
|
+
|
|
334
|
+
async function saveStrategy(agent, request, {signal}={}) {
|
|
335
|
+
return mutate(agent, request, async ({library,signal:innerSignal})=>{
|
|
336
|
+
const draft=readStrategyDraft(request.strategy);
|
|
337
|
+
const existing=draft.id ? library.strategies.find(strategy=>strategy.id===draft.id) : null;
|
|
338
|
+
if(draft.id && !existing) fail(`策略不存在:${draft.id}`);
|
|
339
|
+
assertStrategyNameFree(library,draft.name,draft.id);
|
|
340
|
+
validateStrategyRefs(draft,library.pool,existing);
|
|
341
|
+
const changed=!existing || !sameStrategyContent(existing,draft);
|
|
342
|
+
const isActive=!!(existing && library.active && library.active.strategyId===existing.id);
|
|
343
|
+
// A saved strategy that is not the enabled one only updates the library:
|
|
344
|
+
// existing stale references may still be kept, and no enable-level gate runs.
|
|
345
|
+
if(!isActive) {
|
|
346
|
+
if(!changed) return false;
|
|
347
|
+
if(existing) {
|
|
348
|
+
const next={...existing,name:draft.name,type:draft.type,models:draft.models,fixedProvider:draft.fixedProvider,fixedModel:draft.fixedModel,strategyText:draft.strategyText,version:existing.version+1};
|
|
349
|
+
delete next.legacy;
|
|
350
|
+
delete existing.legacy;
|
|
351
|
+
Object.assign(existing,next);
|
|
352
|
+
} else {
|
|
353
|
+
if(library.strategies.length>=50) fail('策略数量已达上限');
|
|
354
|
+
library.strategies.push({id:randomUUID(),name:draft.name,type:draft.type,models:draft.models,fixedProvider:draft.fixedProvider,fixedModel:draft.fixedModel,strategyText:draft.strategyText,version:1});
|
|
355
|
+
}
|
|
356
|
+
return {library};
|
|
357
|
+
}
|
|
358
|
+
// A re-save with identical content and an already aligned snapshot version
|
|
359
|
+
// writes nothing, so it must not depend on live Host capacity or current
|
|
360
|
+
// model availability: this no-op check comes before those live gates.
|
|
361
|
+
if(!changed && library.active.snapshot.version===existing.version) return false;
|
|
362
|
+
// Saving the enabled strategy takes effect on the next model call: validate
|
|
363
|
+
// the exact next content, then rebuild the active snapshot in the same
|
|
364
|
+
// single-record write. Any failure here leaves the stored active state intact.
|
|
365
|
+
// Older data whose snapshot version lags still needs one alignment write, so
|
|
366
|
+
// it keeps this live validation.
|
|
367
|
+
const host=hostCeilings(), authz=hostAuth(agent);
|
|
368
|
+
const available=await availabilityOf(draft.models,innerSignal);
|
|
369
|
+
validateForUse(draft,library.pool,library.limits,{authorized:authz.routes,available:[...available],ceiling:host.maxDepth,hostActive:host.maxActive,hostActiveKnown:host.maxActiveKnown,hostActiveReason:host.maxActiveReason});
|
|
370
|
+
let version=existing.version+(changed?1:0);
|
|
371
|
+
if(changed && library.active.snapshot.version>=version) version=library.active.snapshot.version+1;
|
|
372
|
+
const candidate=changed?{...draft,version}:{...existing,version};
|
|
373
|
+
const catalog=await modelCatalog(agent,library,authz,innerSignal);
|
|
374
|
+
const applied=buildSnapshotFor(library,candidate,catalog,Date.now());
|
|
375
|
+
if(changed) {
|
|
376
|
+
const next={...existing,name:draft.name,type:draft.type,models:draft.models,fixedProvider:draft.fixedProvider,fixedModel:draft.fixedModel,strategyText:draft.strategyText,version};
|
|
377
|
+
delete next.legacy;
|
|
378
|
+
delete existing.legacy;
|
|
379
|
+
Object.assign(existing,next);
|
|
380
|
+
// A converted legacy strategy no longer reads its old policy copy.
|
|
381
|
+
if(library.legacyPolicy && (existing.id===LEGACY_STRATEGY_ID || existing.id===LEGACY_INHERIT_ID)) library.legacyPolicy=null;
|
|
382
|
+
}
|
|
383
|
+
library.active={strategyId:existing.id,appliedAt:Date.now(),snapshot:applied};
|
|
384
|
+
return {library};
|
|
385
|
+
}, signal);
|
|
386
|
+
}
|
|
387
|
+
async function enableStrategy(agent, request, signal) {
|
|
388
|
+
return mutate(agent, request, async ({library,signal:innerSignal})=>{
|
|
389
|
+
const strategy=library.strategies.find(row=>row.id===request.id);
|
|
390
|
+
if(!strategy) fail(`策略不存在:${String(request.id)}`);
|
|
391
|
+
const authz=hostAuth(agent);
|
|
392
|
+
const available=await availabilityOf(strategy.models,innerSignal);
|
|
393
|
+
const host=hostCeilings();
|
|
394
|
+
validateForUse(strategy,library.pool,library.limits,{authorized:authz.routes,available:[...available],ceiling:host.maxDepth,hostActive:host.maxActive,hostActiveKnown:host.maxActiveKnown,hostActiveReason:host.maxActiveReason});
|
|
395
|
+
const catalog=await modelCatalog(agent,library,authz,innerSignal);
|
|
396
|
+
const applied=buildSnapshotFor(library,strategy,catalog,Date.now());
|
|
397
|
+
if(library.active && library.active.strategyId===strategy.id && sameSnapshotContent(library.active.snapshot,applied)) return false;
|
|
398
|
+
if(library.legacyPolicy && (strategy.id===LEGACY_STRATEGY_ID || strategy.id===LEGACY_INHERIT_ID)) {
|
|
399
|
+
const legacyStrategy=legacyLibrary(library.legacyPolicy).strategies[0];
|
|
400
|
+
if(!sameStrategyContent(legacyStrategy,strategy)) library.legacyPolicy=null;
|
|
401
|
+
}
|
|
402
|
+
library.active={strategyId:strategy.id,appliedAt:Date.now(),snapshot:applied};
|
|
403
|
+
return {library};
|
|
404
|
+
}, signal);
|
|
405
|
+
}
|
|
406
|
+
async function deleteStrategy(agent, request, signal) {
|
|
407
|
+
return mutate(agent, request, async ({library})=>{
|
|
408
|
+
const id=request.id;
|
|
409
|
+
if(typeof id!=='string' || !id || id.length>120) fail('策略 id 无效');
|
|
410
|
+
const strategy=library.strategies.find(row=>row.id===id);
|
|
411
|
+
if(!strategy) fail(`策略不存在:${id}`);
|
|
412
|
+
if(library.active?.strategyId===id) fail(`「${strategy.name}」是当前启用策略,请先关闭策略再删除`);
|
|
413
|
+
const def=globalState().default;
|
|
414
|
+
if(def && typeof def==='object' && def.sourceId===id) fail(`「${strategy.name}」是新会话默认的来源策略,请先更改或清除新会话默认再删除`);
|
|
415
|
+
library.strategies=library.strategies.filter(row=>row.id!==id);
|
|
416
|
+
if(id===LEGACY_STRATEGY_ID || id===LEGACY_INHERIT_ID) library.legacyPolicy=null;
|
|
417
|
+
return {library};
|
|
418
|
+
}, signal);
|
|
419
|
+
}
|
|
420
|
+
async function closeStrategy(agent, request, signal) {
|
|
421
|
+
return mutate(agent, request, async ({library})=>{
|
|
422
|
+
if(!library.active) fail('当前没有启用中的策略');
|
|
423
|
+
library.active=null;
|
|
424
|
+
return {library};
|
|
425
|
+
}, signal);
|
|
426
|
+
}
|
|
427
|
+
async function savePool(agent, request, signal) {
|
|
428
|
+
return mutate(agent, request, async ({library,signal:innerSignal})=>{
|
|
429
|
+
const next=readPoolEntries(request.pool);
|
|
430
|
+
const authz=hostAuth(agent);
|
|
431
|
+
const available=await availabilityOf(next,innerSignal);
|
|
432
|
+
validatePoolSave(next,library.pool,library.active?library.active.snapshot:null,{authorized:authz.routes,available:[...available]});
|
|
433
|
+
library.pool=next;
|
|
434
|
+
// Saved descriptions reach the next model call without changing the saved
|
|
435
|
+
// strategy or its version; the referenced model set is protected above.
|
|
436
|
+
if(library.active) library.active={...library.active,snapshot:refreshSnapshotInfo(library.active.snapshot,next)};
|
|
437
|
+
return {library};
|
|
438
|
+
}, signal);
|
|
439
|
+
}
|
|
440
|
+
async function savePoolNote(agent, request, signal) {
|
|
441
|
+
return mutate(agent, request, async ({library})=>{
|
|
442
|
+
const route={provider:request.provider,model:request.model};
|
|
443
|
+
readRoutes([route],'模型');
|
|
444
|
+
if(typeof request.purpose!=='string' || request.purpose.length>500) fail('简短用途无效或过长');
|
|
445
|
+
if(typeof request.note!=='string' || request.note.length>500) fail('通用使用说明无效或过长');
|
|
446
|
+
const entry=library.pool.find(item=>sameRoute(item,route));
|
|
447
|
+
if(!entry) fail('该模型不在会话池,无法保存说明');
|
|
448
|
+
entry.purpose=request.purpose.trim(); entry.note=request.note.trim();
|
|
449
|
+
const snapshot=library.active?.snapshot;
|
|
450
|
+
if(snapshot && (snapshot.models||[]).some(item=>sameRoute(item,route))) {
|
|
451
|
+
library.active={...library.active,snapshot:refreshSnapshotInfo(snapshot,library.pool)};
|
|
452
|
+
}
|
|
453
|
+
return {library};
|
|
454
|
+
}, signal);
|
|
455
|
+
}
|
|
456
|
+
// Refresh candidates: drop pool entries the Host no longer supports at the
|
|
457
|
+
// moment the sync is initiated, and prune the same unsupported routes out of
|
|
458
|
+
// strategy references so the candidate list really shrinks. The applied
|
|
459
|
+
// snapshot stays protected: any reference it keeps to a route the sync would
|
|
460
|
+
// remove blocks the whole sync.
|
|
461
|
+
async function savePoolSync(agent, request, signal) {
|
|
462
|
+
return mutate(agent, request, async ({library,signal:innerSignal})=>{
|
|
463
|
+
const authz=hostAuth(agent);
|
|
464
|
+
const routes=[...library.pool,...library.strategies.flatMap(strategy=>strategy.models||[])];
|
|
465
|
+
const available=await availabilityOf(routes,innerSignal);
|
|
466
|
+
const plan=planPoolSync(library.pool,{authorized:authz.routes,available:[...available]},library.active?library.active.snapshot:null,library.strategies);
|
|
467
|
+
if(!plan.removed.length && !plan.pruned.length) return false;
|
|
468
|
+
library.pool=plan.kept;
|
|
469
|
+
if(library.active) library.active={...library.active,snapshot:refreshSnapshotInfo(library.active.snapshot,library.pool)};
|
|
470
|
+
let legacyTouched=false;
|
|
471
|
+
for(const item of plan.pruned){
|
|
472
|
+
const strategy=library.strategies.find(row=>row.id===item.id);
|
|
473
|
+
if(!strategy) continue;
|
|
474
|
+
strategy.models=item.models;
|
|
475
|
+
strategy.version=(Number.isSafeInteger(strategy.version)?strategy.version:1)+1;
|
|
476
|
+
if(strategy.id===LEGACY_STRATEGY_ID || strategy.id===LEGACY_INHERIT_ID) legacyTouched=true;
|
|
477
|
+
}
|
|
478
|
+
if(library.legacyPolicy && legacyTouched){
|
|
479
|
+
const keptKeys=new Set(library.strategies.flatMap(strategy=>(strategy.models||[]).map(routeKey)));
|
|
480
|
+
library.legacyPolicy={...library.legacyPolicy,models:(library.legacyPolicy.models||[]).filter(model=>keptKeys.has(routeKey(model)))};
|
|
481
|
+
}
|
|
482
|
+
return {library};
|
|
483
|
+
}, signal);
|
|
484
|
+
}
|
|
485
|
+
async function saveLimits(agent, request, signal) {
|
|
486
|
+
return mutate(agent, request, async ({library})=>{
|
|
487
|
+
const limits=request.limits;
|
|
488
|
+
if(!limits || typeof limits!=='object' || Array.isArray(limits) || Object.keys(limits).some(key=>!['maxDepth','maxActive'].includes(key))) fail('限制参数无效');
|
|
489
|
+
// Read the live Host value on every write; no cache, no fallback to 8/32.
|
|
490
|
+
const host=hostCeilings();
|
|
491
|
+
if(!Number.isSafeInteger(limits.maxDepth) || limits.maxDepth<0 || limits.maxDepth>host.maxDepth) fail(`最大递归深度必须是 0–${host.maxDepth} 的整数`);
|
|
492
|
+
assertHostActive(limits.maxActive,host);
|
|
493
|
+
library.limits={maxDepth:limits.maxDepth,maxActive:limits.maxActive};
|
|
494
|
+
return {library};
|
|
495
|
+
}, signal);
|
|
496
|
+
}
|
|
497
|
+
async function setDefault(agent, request, signal) {
|
|
498
|
+
ensureRoot(agent);
|
|
499
|
+
return enqueue(async()=>{
|
|
500
|
+
if(closed) fail('插件已卸载');
|
|
501
|
+
signal?.throwIfAborted();
|
|
502
|
+
const global=globalState();
|
|
503
|
+
if(request.expectedDefaultRevision!==global.revision) fail('默认版本冲突:新会话默认已在其他位置更新,请刷新后重试');
|
|
504
|
+
const stored=policies.get(agent.id);
|
|
505
|
+
if(expectedRevision(request)!==(stored?.revision ?? 0)) fail('版本冲突:策略已在其他位置更新,请刷新后重试');
|
|
506
|
+
if(typeof request.id!=='string' || !request.id) fail('策略 id 无效');
|
|
507
|
+
const record=recordView(agent.id,agent);
|
|
508
|
+
if(record.issue) fail(record.issue);
|
|
509
|
+
const library=record.explicit ? readLibrary(record.library) : record.absent ? emptyLibrary({now:Date.now()}) : materializeLegacy(record.policy);
|
|
510
|
+
const strategy=library.strategies.find(row=>row.id===request.id);
|
|
511
|
+
if(!strategy) fail(`策略不存在:${request.id}`);
|
|
512
|
+
if(strategy.type==='inherit') fail('跟随类型不能设为新会话默认;请先保存为固定或 Agent 自选策略');
|
|
513
|
+
const authz=hostAuth(agent);
|
|
514
|
+
const catalog=await modelCatalog(agent,library,authz,signal);
|
|
515
|
+
const pool=library.pool.map(entry=>({...entry,name:catalog.get(routeKey(entry))?.name || entry.model}));
|
|
516
|
+
const value=defaultFromStrategy(strategy,pool,library.limits,{now:Date.now()});
|
|
517
|
+
signal?.throwIfAborted();
|
|
518
|
+
if(closed) fail('插件已卸载');
|
|
519
|
+
await domain.global.set({revision:global.revision+1,default:value});
|
|
520
|
+
return snapshot(agent);
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
async function clearDefault(agent, request, signal) {
|
|
524
|
+
ensureRoot(agent);
|
|
525
|
+
return enqueue(async()=>{
|
|
526
|
+
if(closed) fail('插件已卸载');
|
|
527
|
+
signal?.throwIfAborted();
|
|
528
|
+
const global=globalState();
|
|
529
|
+
if(request.expectedDefaultRevision!==global.revision) fail('默认版本冲突:新会话默认已在其他位置更新,请刷新后重试');
|
|
530
|
+
await domain.global.set({revision:global.revision+1,default:null});
|
|
531
|
+
return snapshot(agent);
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
async function saveLegacy(agent, request, reset=false, signal) {
|
|
535
|
+
ensureRoot(agent);
|
|
536
|
+
return enqueue(async()=>{
|
|
537
|
+
if(closed) fail('插件已卸载');
|
|
538
|
+
signal?.throwIfAborted();
|
|
539
|
+
const stored=policies.get(agent.id);
|
|
540
|
+
const revision=stored?.revision ?? 0;
|
|
541
|
+
if(expectedRevision(request)!==revision) fail('版本冲突:策略已在其他位置更新,请刷新后重试');
|
|
542
|
+
if(!stored || stored.library===undefined) {
|
|
543
|
+
const next=reset ? defaults(hostDepth(),officialRoutes(agent)) : validatePolicy(request.policy,hostDepth(),officialRoutes(agent));
|
|
544
|
+
// The old v1 save entry must not write a live-child limit beyond the
|
|
545
|
+
// real Host capacity either; reset is an explicit close, not a limit write.
|
|
546
|
+
if(!reset) assertHostActive(next.maxActive,hostCeilings());
|
|
547
|
+
await preflight(next,signal);
|
|
548
|
+
signal?.throwIfAborted();
|
|
549
|
+
await policies.put(agent.id,{revision:revision+1,policy:next});
|
|
550
|
+
return snapshot(agent);
|
|
551
|
+
}
|
|
552
|
+
const library=readLibrary(stored.library);
|
|
553
|
+
if(reset) {
|
|
554
|
+
library.active=null;
|
|
555
|
+
await policies.put(agent.id,{revision:revision+1,policy:buildPolicy(library,hostDepth()),library});
|
|
556
|
+
return snapshot(agent);
|
|
557
|
+
}
|
|
558
|
+
const policy=validatePolicy(request.policy,hostDepth(),officialRoutes(agent));
|
|
559
|
+
if(policy.mode==='inherit') {
|
|
560
|
+
if(policy.enabled) fail('旧版跟随模式不能直接写入多策略库;请在界面中关闭或保存为固定/自选策略');
|
|
561
|
+
// Old clients save a disabled inherit draft to mean "nothing enabled".
|
|
562
|
+
// The limits it stores still obey the same live Host gate.
|
|
563
|
+
assertHostActive(policy.maxActive,hostCeilings());
|
|
564
|
+
library.active=null;
|
|
565
|
+
library.limits={maxDepth:policy.maxDepth,maxActive:policy.maxActive};
|
|
566
|
+
await policies.put(agent.id,{revision:revision+1,policy:buildPolicy(library,hostDepth()),library});
|
|
567
|
+
return snapshot(agent);
|
|
568
|
+
}
|
|
569
|
+
await preflight(policy,signal);
|
|
570
|
+
// Old save is another limit/enable write; it shares the same live Host
|
|
571
|
+
// capacity gate as limits.save / strategy.enable / saving the enabled strategy.
|
|
572
|
+
assertHostActive(policy.maxActive,hostCeilings());
|
|
573
|
+
const auth=new Set(officialRoutes(agent).map(routeKey));
|
|
574
|
+
const pool=new Map(library.pool.map(entry=>[routeKey(entry),entry]));
|
|
575
|
+
const additions=[];
|
|
576
|
+
for(const model of policy.models) {
|
|
577
|
+
const key=routeKey(model);
|
|
578
|
+
if(!pool.has(key)) {
|
|
579
|
+
if(pool.size>=24) fail('模型池最多 24 个模型');
|
|
580
|
+
if(!auth.has(key)) fail(`模型未获当前宿主授权:${model.provider}/${model.model}`);
|
|
581
|
+
const entry={provider:model.provider,model:model.model,purpose:model.scenarios||'',note:model.limitations||''};
|
|
582
|
+
pool.set(key,entry);
|
|
583
|
+
additions.push(entry);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
// A disabled legacy save also cannot bypass the model-pool gate: additions
|
|
587
|
+
// that pool.save would reject for unavailability must be rejected here too.
|
|
588
|
+
if(!policy.enabled && additions.length) {
|
|
589
|
+
const available=await availabilityOf(additions,signal);
|
|
590
|
+
validatePoolSave([...pool.values()],library.pool,null,{authorized:officialRoutes(agent),available:[...available]});
|
|
591
|
+
}
|
|
592
|
+
library.pool=[...pool.values()];
|
|
593
|
+
library.limits={maxDepth:policy.maxDepth,maxActive:policy.maxActive};
|
|
594
|
+
if(!policy.enabled) library.active=null;
|
|
595
|
+
else {
|
|
596
|
+
library.legacyPolicy=null;
|
|
597
|
+
// An old client can still submit a v1 fixed policy with several
|
|
598
|
+
// candidates. Keep that exact legacy form (and its non-first target) so
|
|
599
|
+
// the written library stays readable instead of failing the next read.
|
|
600
|
+
const legacyForm=policy.mode==='fixed' && policy.models.length>1;
|
|
601
|
+
const activeId=library.active?.strategyId;
|
|
602
|
+
let strategy=activeId ? library.strategies.find(row=>row.id===activeId) : null;
|
|
603
|
+
if(!strategy) {
|
|
604
|
+
if(library.strategies.length>=50) fail('策略数量已达上限');
|
|
605
|
+
strategy={id:randomUUID(),name:'会话策略',type:policy.mode,models:policy.models.map(model=>({provider:model.provider,model:model.model})),fixedProvider:policy.fixedProvider,fixedModel:policy.fixedModel,strategyText:policy.strategyText,version:1};
|
|
606
|
+
if(legacyForm) strategy.legacy=true;
|
|
607
|
+
library.strategies.push(strategy);
|
|
608
|
+
} else {
|
|
609
|
+
const next={...strategy,type:policy.mode,models:policy.models.map(model=>({provider:model.provider,model:model.model})),fixedProvider:policy.fixedProvider,fixedModel:policy.fixedModel,strategyText:policy.strategyText};
|
|
610
|
+
if(!sameStrategyContent(strategy,next)) Object.assign(strategy,{...next,version:strategy.version+1}); else Object.assign(strategy,next);
|
|
611
|
+
if(legacyForm) strategy.legacy=true; else delete strategy.legacy;
|
|
612
|
+
}
|
|
613
|
+
const catalog=await modelCatalog(agent,library,hostAuth(agent),signal);
|
|
614
|
+
library.active={strategyId:strategy.id,appliedAt:Date.now(),snapshot:buildSnapshotFor(library,strategy,catalog,Date.now())};
|
|
615
|
+
}
|
|
616
|
+
signal?.throwIfAborted();
|
|
617
|
+
await policies.put(agent.id,{revision:revision+1,policy:buildPolicy(library,hostDepth()),library});
|
|
618
|
+
return snapshot(agent);
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// A brand-new, non-seed, non-fork root imports the default ONCE, closed.
|
|
623
|
+
// Resumes, forks, seeded sessions and existing live sessions never import.
|
|
624
|
+
async function initializeRoot({agent,source}) {
|
|
625
|
+
if(source!=='startup') return;
|
|
626
|
+
if(ctx.agents.currentInitiator()!==undefined) return;
|
|
627
|
+
const s=session(agent); const header=s?.header;
|
|
628
|
+
if(!s || header.origin==='subagent' || header.isSeeded===true || header.parentSession) return;
|
|
629
|
+
return enqueue(async()=>{
|
|
630
|
+
if(closed) return;
|
|
631
|
+
if(policies.get(agent.id)) return;
|
|
632
|
+
const global=globalState();
|
|
633
|
+
const def=readDefaultSafe(global).value;
|
|
634
|
+
const now=Date.now();
|
|
635
|
+
const library=def
|
|
636
|
+
? importDefault(def,{now,id:randomUUID(),defaultRevision:global.revision})
|
|
637
|
+
: emptyLibrary({now});
|
|
638
|
+
await policies.put(agent.id,{revision:0,policy:buildPolicy(library,hostDepth()),library});
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
ctx.commands.register({name:'subagent-policy',description:'查看或由用户配置当前会话的子智能体模型选择策略',recordInput:true,input:{hint:'JSON action: read / save / strategy.save / pool.save / limits.save …'},
|
|
643
|
+
async handler(inv){
|
|
644
|
+
try {
|
|
645
|
+
if(inv.rawInput.length>40000) fail('请求过长');
|
|
646
|
+
const request=inv.rawInput.trim()?JSON.parse(inv.rawInput):{action:'read'};
|
|
647
|
+
if(!request || typeof request!=='object' || Array.isArray(request)) fail('请求必须是 JSON 对象');
|
|
648
|
+
const action=request.action ?? 'read';
|
|
649
|
+
if(typeof action!=='string' || !ACTIONS.has(action)) fail('未知 action');
|
|
650
|
+
let value;
|
|
651
|
+
if(action==='read') {
|
|
652
|
+
rejectUnknown(request,['action']);
|
|
653
|
+
value=await snapshot(inv.agent);
|
|
654
|
+
} else {
|
|
655
|
+
// No mutation Tool is exposed. A model-initiated in-process command is denied too.
|
|
656
|
+
if(ctx.agents.currentInitiator()!==undefined) fail('策略编辑为用户操作,Agent 不可自行授权');
|
|
657
|
+
inv.signal.throwIfAborted();
|
|
658
|
+
if(action==='save'||action==='reset') {
|
|
659
|
+
rejectUnknown(request,action==='save'?['action','expectedRevision','policy']:['action','expectedRevision']);
|
|
660
|
+
value=await saveLegacy(inv.agent,request,action==='reset',inv.signal);
|
|
661
|
+
} else if(action==='strategy.save') {
|
|
662
|
+
rejectUnknown(request,['action','expectedRevision','strategy']);
|
|
663
|
+
value=await saveStrategy(inv.agent,request,{signal:inv.signal});
|
|
664
|
+
} else if(action==='strategy.enable') {
|
|
665
|
+
rejectUnknown(request,['action','expectedRevision','id']);
|
|
666
|
+
if(typeof request.id!=='string' || !request.id || request.id.length>120) fail('策略 id 无效');
|
|
667
|
+
value=await enableStrategy(inv.agent,request,inv.signal);
|
|
668
|
+
} else if(action==='strategy.delete') {
|
|
669
|
+
rejectUnknown(request,['action','expectedRevision','id']);
|
|
670
|
+
value=await deleteStrategy(inv.agent,request,inv.signal);
|
|
671
|
+
} else if(action==='strategy.close') {
|
|
672
|
+
rejectUnknown(request,['action','expectedRevision']);
|
|
673
|
+
value=await closeStrategy(inv.agent,request,inv.signal);
|
|
674
|
+
} else if(action==='pool.save') {
|
|
675
|
+
rejectUnknown(request,['action','expectedRevision','pool']);
|
|
676
|
+
value=await savePool(inv.agent,request,inv.signal);
|
|
677
|
+
} else if(action==='pool.note') {
|
|
678
|
+
rejectUnknown(request,['action','expectedRevision','provider','model','purpose','note']);
|
|
679
|
+
value=await savePoolNote(inv.agent,request,inv.signal);
|
|
680
|
+
} else if(action==='pool.sync') {
|
|
681
|
+
rejectUnknown(request,['action','expectedRevision']);
|
|
682
|
+
value=await savePoolSync(inv.agent,request,inv.signal);
|
|
683
|
+
} else if(action==='limits.save') {
|
|
684
|
+
rejectUnknown(request,['action','expectedRevision','limits']);
|
|
685
|
+
value=await saveLimits(inv.agent,request,inv.signal);
|
|
686
|
+
} else if(action==='strategy.default') {
|
|
687
|
+
rejectUnknown(request,['action','expectedRevision','expectedDefaultRevision','id']);
|
|
688
|
+
if(!Number.isSafeInteger(request.expectedDefaultRevision) || request.expectedDefaultRevision<0) fail('expectedDefaultRevision 无效');
|
|
689
|
+
if(typeof request.id!=='string' || !request.id || request.id.length>120) fail('策略 id 无效');
|
|
690
|
+
value=await setDefault(inv.agent,request,inv.signal);
|
|
691
|
+
} else if(action==='default.clear') {
|
|
692
|
+
rejectUnknown(request,['action','expectedDefaultRevision']);
|
|
693
|
+
if(!Number.isSafeInteger(request.expectedDefaultRevision) || request.expectedDefaultRevision<0) fail('expectedDefaultRevision 无效');
|
|
694
|
+
value=await clearDefault(inv.agent,request,inv.signal);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return {kind:'success',text:JSON.stringify(value)};
|
|
698
|
+
} catch(error){return {kind:'error',text:error instanceof SyntaxError?'策略请求不是合法 JSON':String(error.message ?? error)};}
|
|
699
|
+
}});
|
|
700
|
+
ctx.tools.register({name:'subagent_policy',description:'Read the current session subagent policy, exact authorized routes, model usage notes, injection preview and execution evidence. Does not change policy.',parameters:{type:'object',properties:{},additionalProperties:false},
|
|
701
|
+
output:{schema:{type:'object',additionalProperties:true},render},execute:(_args,exec)=>snapshot(exec.agent)});
|
|
702
|
+
|
|
703
|
+
function localChild(agent) {return session(agent)?.header.origin==='subagent';}
|
|
704
|
+
function creationRoute(agent) {return {provider:agent.options?.provider,model:agent.options?.model};}
|
|
705
|
+
function count(id,except) {
|
|
706
|
+
const counted=new Set([...leases].filter(([child,l])=>l.rootId===id && child!==except).map(([child])=>child));
|
|
707
|
+
// Count acknowledged residents, NOT all registry entries: siblings may be entered
|
|
708
|
+
// but still awaiting this very admission event.
|
|
709
|
+
for(const a of residents.values()) if(a.id!==except && rootId(a)===id) counted.add(a.id);
|
|
710
|
+
return counted.size;
|
|
711
|
+
}
|
|
712
|
+
async function attach({agent,source}) {
|
|
713
|
+
const s=session(agent);
|
|
714
|
+
if(!s || !localChild(agent)) return;
|
|
715
|
+
const parent=ctx.agents.get(s.header.parentSession);
|
|
716
|
+
const previous=runs.get(agent.id);
|
|
717
|
+
const root=parent ? rootId(parent) : previous?.rootId;
|
|
718
|
+
const pin=pending.get(agent.id);
|
|
719
|
+
const rec=pin?.record ?? (source==='resume' && previous?.policy ? {revision:previous.version,policy:previous.policy} : parent ? recordFor(parent):undefined);
|
|
720
|
+
if(!rec?.policy.enabled || !root) {residents.set(agent.id,agent);return;}
|
|
721
|
+
const route=creationRoute(agent), base=previous ? {provider:previous.requestedProvider,model:previous.requestedModel} : parent?baseline(parent):route;
|
|
722
|
+
// The Host samples its own pool at each materialization; when its live value
|
|
723
|
+
// is readable, this plugin's admission narrows to that value. Unknown is not
|
|
724
|
+
// guessed here: the Host still enforces its own capacity.
|
|
725
|
+
const host=hostCeilings();
|
|
726
|
+
checkAdmission(rec.policy,route,base,s.header.delegationDepth ?? 1,count(root,agent.id),host.maxDepth,host.maxActiveKnown?host.maxActive:null);
|
|
727
|
+
if(rec.policy.mode!=='inherit') {
|
|
728
|
+
const auth=new Set((parent?officialRoutes(parent):[]).map(routeKey));
|
|
729
|
+
if(parent && !auth.has(routeKey(route))) fail('创建模型已不在当前官方授权范围');
|
|
730
|
+
}
|
|
731
|
+
// Reservation is synchronous before the first await, so sibling admissions cannot race.
|
|
732
|
+
leases.set(agent.id,{agent,rootId:root,policy:clone(rec.policy),version:rec.revision,baseline:base});
|
|
733
|
+
residents.set(agent.id,agent);
|
|
734
|
+
try {
|
|
735
|
+
if(!previous) {
|
|
736
|
+
const meta=activeMeta(rec);
|
|
737
|
+
await runs.put(agent.id,{id:agent.id,rootId:root,parentId:s.header.parentSession,label:'本地子智能体',requestedProvider:route.provider ?? '',requestedModel:route.model ?? '',version:rec.revision,policy:clone(rec.policy),reason:'原生入口;未记录显式选择依据',status:'admitted',createdAt:Date.now(),...meta});
|
|
738
|
+
}
|
|
739
|
+
} catch(e){leases.delete(agent.id);residents.delete(agent.id);throw e;}
|
|
740
|
+
}
|
|
741
|
+
ctx.on('agent/created',async payload=>{
|
|
742
|
+
try { await initializeRoot(payload); } catch(error) { ctx.logger.warn(`subagent-policy default initialization: ${String(error)}`); }
|
|
743
|
+
await attach(payload);
|
|
744
|
+
});
|
|
745
|
+
// Existing agents are not retroactively rejected; resume uses its saved creation snapshot.
|
|
746
|
+
for(const agent of ctx.agents.list()) {
|
|
747
|
+
if(localChild(agent)) residents.set(agent.id,agent);
|
|
748
|
+
const old=runs.get(agent.id);
|
|
749
|
+
if(old?.policy) leases.set(agent.id,{agent,rootId:old.rootId,policy:old.policy,version:old.version,baseline:creationRoute(agent)});
|
|
750
|
+
}
|
|
751
|
+
ctx.on('agent/disposed',({agent})=>{
|
|
752
|
+
if(residents.get(agent.id)===agent) residents.delete(agent.id);
|
|
753
|
+
if(leases.get(agent.id)?.agent !== agent) return;
|
|
754
|
+
leases.delete(agent.id);
|
|
755
|
+
const row=runs.get(agent.id); const s=session(agent);
|
|
756
|
+
if(row) {
|
|
757
|
+
const ev=s?ctx.sessionProjections.stateOf(s,'lezSubagentEvidence'):undefined;
|
|
758
|
+
void runs.update(agent.id,current=>({...current,status:'disposed',...(ev?{evidenceSnapshot:clone(ev)}:{})})).catch(e=>ctx.logger.warn(`subagent-policy audit persistence: ${String(e)}`));
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
ctx.on('subagent/start',info=>{
|
|
762
|
+
if(!info.local) return;
|
|
763
|
+
const row=runs.get(info.id);
|
|
764
|
+
if(row) void runs.update(info.id,current=>({...current,backend:info.provider,status:current.status==='disposed'?'disposed':'started'})).catch(e=>ctx.logger.warn(String(e)));
|
|
765
|
+
});
|
|
766
|
+
// Final outbound validation, not routing: no mutation of options or silent model replacement.
|
|
767
|
+
ctx.on('llm/stream',function(options,next){
|
|
768
|
+
if(options.purpose || !options.sessionId) return next();
|
|
769
|
+
const lease=leases.get(options.sessionId);
|
|
770
|
+
if(!lease) return next();
|
|
771
|
+
const p=lease.policy, route={provider:options.provider,model:options.model};
|
|
772
|
+
if(p.mode!=='inherit') {
|
|
773
|
+
const allowed=ctx.subagentModelSelection.current();
|
|
774
|
+
if(!allowed.enabled || !allowed.allowedModels.some(r=>sameRoute(r,route))) fail('该子智能体模型已被宿主撤销授权');
|
|
775
|
+
}
|
|
776
|
+
if(p.mode==='inherit' && !sameRoute(route,lease.baseline)) fail('子智能体请求模型偏离创建时跟随模型');
|
|
777
|
+
if(p.mode==='fixed' && !sameRoute(route,{provider:p.fixedProvider,model:p.fixedModel})) fail('子智能体请求模型偏离固定策略');
|
|
778
|
+
if(p.mode==='auto' && !p.models.some(r=>sameRoute(r,route))) fail('子智能体请求模型超出会话授权');
|
|
779
|
+
return next();
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
ctx.tools.register({name:'delegate_with_policy',description:'Create one local subagent using this session policy; returns its id immediately and delivers completion through the native subagent runtime. Choose an authorized exact model for auto mode and provide a short selection reason. Does not change the main model or policy.',
|
|
783
|
+
parameters:{type:'object',additionalProperties:false,required:['prompt','description','reason'],properties:{prompt:{type:'string',description:'Self-contained child task; fork inherits completed conversation turns.'},description:{type:'string',description:'Short task label.'},reason:{type:'string',description:'One short selection reason, not a chain of thought.'},context:{type:'string',enum:['fresh','fork'],description:'fresh (default) or fork.'},provider:{type:'string',description:'Exact LLM provider; supply together with model. Required in auto mode.'},model:{type:'string',description:'Exact authorized model; supply with provider.'},reasoning_effort:{type:'string',description:'Optional effort supported by the selected model; omitted uses its default.'}}},
|
|
784
|
+
isConcurrencySafe:()=>true,output:{schema:{type:'object',additionalProperties:true},render},
|
|
785
|
+
async execute(args,exec){
|
|
786
|
+
const agent=exec.agent, rec=recordFor(agent), p=rec.policy;
|
|
787
|
+
if(!p.enabled) fail('当前会话尚未启用策略,请由用户在配置栏启用');
|
|
788
|
+
if(typeof args.prompt!=='string'||!args.prompt.trim()||args.prompt.length>100000) fail('任务提示词无效或过长');
|
|
789
|
+
if(typeof args.description!=='string'||args.description.length>200||typeof args.reason!=='string'||!args.reason.trim()||args.reason.length>500) fail('任务标签或选择依据无效');
|
|
790
|
+
if(args.context!==undefined && !['fresh','fork'].includes(args.context)) fail('context 无效');
|
|
791
|
+
const route=selectRoute(p,baseline(agent),args);
|
|
792
|
+
if(p.mode!=='inherit' && !officialRoutes(agent).some(r=>sameRoute(r,route))) fail('宿主授权已变更或当前会话未获得此模型授权');
|
|
793
|
+
const backend=args.context==='fork'?'fork':'spawn', provider=ctx.subagents.getProvider(backend);
|
|
794
|
+
if(!provider?.prepareContinuable || !provider.capabilities.agentOptions || !provider.capabilities.depthLimit) fail('此本地后端缺少所需能力');
|
|
795
|
+
const selected=await ctx.llm.resolveCallConfig({...route,...args.reasoning_effort===undefined?{}:{reasoningEffort:args.reasoning_effort}},exec.signal);
|
|
796
|
+
exec.signal.throwIfAborted();
|
|
797
|
+
if(closed) fail('插件已卸载');
|
|
798
|
+
if(recordFor(agent).revision!==rec.revision) fail('模型预检期间策略已更新,请重新读取后委派');
|
|
799
|
+
// Pin once creation begins; a save while materialization awaits cannot relabel it.
|
|
800
|
+
const childId=randomUUID();
|
|
801
|
+
pending.set(childId,{record:clone(rec)});
|
|
802
|
+
let started;
|
|
803
|
+
try {
|
|
804
|
+
started=await ctx.subagents.startActivation({childId,provider:backend,label:args.description,delivery:'parent',signal:exec.signal,request:{parent:agent,prompt:[{type:'text',text:args.prompt}],agentOptions:selected,maxDepth:Math.min(p.maxDepth,hostDepth())}});
|
|
805
|
+
} finally {pending.delete(childId);}
|
|
806
|
+
const row=runs.get(started.childId);
|
|
807
|
+
if(row) await runs.update(started.childId,current=>({...current,label:args.description,reason:args.reason,entry:'delegate_with_policy'})).catch(e=>ctx.logger.warn(`child started; audit label save failed: ${String(e)}`));
|
|
808
|
+
return {subagentId:started.childId,provider:route.provider,model:route.model,policyVersion:rec.revision};
|
|
809
|
+
}});
|
|
810
|
+
}
|