v1.1.0: composer advisor picker (model + effort submenu, off/same-as-agent), live settings route, tests
This commit is contained in:
1 parent
a47bcb03ab
commit
22dcc3a29c
4 files changed
+417
-12
No files matched your search
@@ -0,0 +1,276 @@
|
|||||||
|
// omp-advisor Web client: composer control to pick the advisor model (or turn it off).
|
||||||
|
//
|
||||||
|
// Lives in `conversation.input.right` (a list slot) next to the model seat. The menu lists the
|
||||||
|
// same provider-grouped models as DSH's model picker (the session's model directory), plus
|
||||||
|
// "Off" and "Same as agent". Hovering a model that has reasoning efforts opens a side submenu to
|
||||||
|
// pick its effort; clicking the model row picks it with its default effort. The choice is saved
|
||||||
|
// on the host (POST omp-advisor/settings) and applies from the advisor's next review.
|
||||||
|
window.__ModuleLoader__.load({
|
||||||
|
id: '@local/omp-advisor',
|
||||||
|
factory(require) {
|
||||||
|
const React = require('react');
|
||||||
|
const ReactDOM = require('react-dom');
|
||||||
|
const P = require('@deepseek-ai/dsh-client-ui-primitives');
|
||||||
|
const h = React.createElement;
|
||||||
|
const NS = 'ompAdvisor';
|
||||||
|
|
||||||
|
const en = {
|
||||||
|
label: 'Advisor', off: 'Advisor off', same: 'Same as agent', offItem: 'Off',
|
||||||
|
sameItem: 'Same model as the agent', title: 'Second-model advisor', effort: 'Effort',
|
||||||
|
providerDefault: 'Default', loading: 'Loading models…', error: 'Could not save: {msg}',
|
||||||
|
hint: 'Reviews each step and adds notes or blockers.',
|
||||||
|
};
|
||||||
|
const zh = {
|
||||||
|
label: '顾问', off: '顾问关闭', same: '同智能体', offItem: '关闭',
|
||||||
|
sameItem: '与智能体相同的模型', title: '第二模型顾问', effort: '推理强度',
|
||||||
|
providerDefault: '默认', loading: '正在加载模型…', error: '保存失败:{msg}',
|
||||||
|
hint: '审查每一步并给出提示或阻断意见。',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
.oadv-root{position:relative;display:flex;align-items:center;min-width:0}
|
||||||
|
.oadv-trigger{border:0;background:none;color:var(--dsw-alias-label-secondary);height:28px;max-width:200px;padding:0 6px 0 6px;border-radius:var(--dsw-radius-sm);display:flex;align-items:center;gap:5px;font:inherit;font-size:13px;cursor:pointer;min-width:0}
|
||||||
|
.oadv-trigger:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
||||||
|
.oadv-trigger[data-off]{color:var(--dsw-alias-label-caption)}
|
||||||
|
.oadv-trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline:none}
|
||||||
|
.oadv-label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
|
||||||
|
.oadv-effort{color:var(--dsw-alias-label-caption);white-space:nowrap}
|
||||||
|
.oadv-menu,.oadv-sub{position:fixed;z-index:1100;min-width:240px;max-width:min(380px,100vw - 32px);max-height:min(420px,100vh - 96px);overflow-y:auto;padding:4px;color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-elevation-prominent);border-radius:var(--dsw-radius-lg,10px);background:var(--dsw-alias-bg-l1,var(--dsw-alias-surface-l1,#1e1e1e))}
|
||||||
|
.oadv-sub{min-width:160px}
|
||||||
|
.oadv-head{padding:6px 8px 4px;font-size:12px;color:var(--dsw-alias-label-tertiary)}
|
||||||
|
.oadv-group{padding:8px 8px 2px;font-size:11px;letter-spacing:.02em;color:var(--dsw-alias-label-caption);text-transform:uppercase}
|
||||||
|
.oadv-item{width:100%;display:flex;align-items:center;gap:8px;min-height:32px;padding:0 8px;border:0;background:none;color:inherit;font:inherit;font-size:13px;text-align:left;border-radius:var(--dsw-radius-md);cursor:pointer}
|
||||||
|
.oadv-item:hover,.oadv-item[data-active]{background:var(--dsw-alias-interactive-bg-hover)}
|
||||||
|
.oadv-item-name{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.oadv-item-meta{color:var(--dsw-alias-label-caption);font-size:12px;white-space:nowrap}
|
||||||
|
.oadv-check{width:14px;flex:none;display:inline-flex;justify-content:center}
|
||||||
|
.oadv-sep{height:1px;margin:4px 6px;background:var(--dsw-alias-border-l1,rgba(127,127,127,.2))}
|
||||||
|
.oadv-status{padding:8px;font-size:12px;color:var(--dsw-alias-label-tertiary)}
|
||||||
|
.oadv-err{padding:6px 8px;font-size:12px;color:var(--dsw-alias-state-danger,#f85149)}
|
||||||
|
`;
|
||||||
|
let cssDone = false;
|
||||||
|
function installCss() {
|
||||||
|
if (cssDone || typeof document === 'undefined') return;
|
||||||
|
const s = document.createElement('style');
|
||||||
|
s.dataset.ompAdvisor = '';
|
||||||
|
s.textContent = CSS;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
cssDone = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ic = (name) => P[name] || (() => null);
|
||||||
|
const IconCheck = Ic('IconCheckOutlineRegular');
|
||||||
|
const IconChevronDown = Ic('IconChevronDownOutlineRegular');
|
||||||
|
const IconChevronRight = Ic('IconChevronRightOutlineRegular');
|
||||||
|
const IconAdvisor = P.IconSparkleRegular || P.IconEyeOutlineRegular || (() => null);
|
||||||
|
|
||||||
|
const fill = (s, v) => String(s).replace(/\{(\w+)\}/g, (_, k) => (v && v[k] !== undefined ? v[k] : ''));
|
||||||
|
const effortName = (e) => (e?.id === 'xhigh' ? 'Extra' : e?.name ?? e?.id);
|
||||||
|
|
||||||
|
// ---- host settings store (shared by every session's control) ----
|
||||||
|
const settings = (() => {
|
||||||
|
let value = null;
|
||||||
|
let inflight = null;
|
||||||
|
const subs = new Set();
|
||||||
|
const emit = () => subs.forEach((f) => f());
|
||||||
|
async function call(init) {
|
||||||
|
const res = await fetch('omp-advisor/settings', { ...init, headers: { accept: 'application/json', ...(init?.body ? { 'content-type': 'application/json' } : {}) } });
|
||||||
|
let json = {};
|
||||||
|
try { json = await res.json(); } catch { /* empty */ }
|
||||||
|
if (!res.ok) throw new Error(json.message || res.statusText || String(res.status));
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
subscribe(f) { subs.add(f); return () => subs.delete(f); },
|
||||||
|
get() { return value; },
|
||||||
|
load() {
|
||||||
|
inflight ??= call().then((v) => { value = v; emit(); }).catch(() => {}).finally(() => { inflight = null; });
|
||||||
|
return inflight;
|
||||||
|
},
|
||||||
|
async save(patch) {
|
||||||
|
const prev = value;
|
||||||
|
value = { ...(value ?? {}), ...patch };
|
||||||
|
emit();
|
||||||
|
try { value = await call({ method: 'POST', body: JSON.stringify(patch) }); emit(); } catch (e) { value = prev; emit(); throw e; }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
function useSettings() {
|
||||||
|
const v = React.useSyncExternalStore(settings.subscribe, settings.get);
|
||||||
|
React.useEffect(() => { if (v === null) settings.load(); }, [v]);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function place(anchor, menu, { side = 'above', gap = 6 } = {}) {
|
||||||
|
const a = anchor.getBoundingClientRect();
|
||||||
|
const m = menu.getBoundingClientRect();
|
||||||
|
const vw = window.innerWidth; const vh = window.innerHeight;
|
||||||
|
if (side === 'right') {
|
||||||
|
let left = a.right + 2;
|
||||||
|
if (left + m.width > vw - 8) left = a.left - m.width - 2;
|
||||||
|
let top = Math.min(a.top - 4, vh - m.height - 8);
|
||||||
|
return { left: Math.max(8, left), top: Math.max(8, top) };
|
||||||
|
}
|
||||||
|
let left = Math.min(a.left, vw - m.width - 8);
|
||||||
|
let top = a.top - m.height - gap;
|
||||||
|
if (top < 8) top = a.bottom + gap;
|
||||||
|
return { left: Math.max(8, left), top: Math.max(8, Math.min(top, vh - m.height - 8)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdvisorPicker({ directory, load, t }) {
|
||||||
|
React.useEffect(installCss, []);
|
||||||
|
const st = useSettings();
|
||||||
|
const dir = React.useSyncExternalStore((f) => directory.subscribe(f), () => directory.getSnapshot());
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [hover, setHover] = React.useState(null); // { key, el, model, group }
|
||||||
|
const [err, setErr] = React.useState(null);
|
||||||
|
const [pos, setPos] = React.useState(null);
|
||||||
|
const [subPos, setSubPos] = React.useState(null);
|
||||||
|
const rootRef = React.useRef(null);
|
||||||
|
const trigRef = React.useRef(null);
|
||||||
|
const menuRef = React.useRef(null);
|
||||||
|
const subRef = React.useRef(null);
|
||||||
|
const hoverTimer = React.useRef(null);
|
||||||
|
|
||||||
|
const enabled = st?.enabled !== false;
|
||||||
|
const own = !!(st?.provider && st?.model);
|
||||||
|
const groups = dir?.groups ?? [];
|
||||||
|
const current = own ? groups.find((g) => g.id === st.provider)?.models.find((m) => m.id === st.model) : null;
|
||||||
|
const effortOf = (model, id) => effortName(model?.reasoning?.efforts?.find((e) => e.id === id)) ?? id;
|
||||||
|
|
||||||
|
const label = !st ? t('label') : !enabled ? t('off') : own ? (current?.name ?? st.model) : t('same');
|
||||||
|
const effortLabel = enabled && own && current?.reasoning
|
||||||
|
? (st.reasoningEffort ? effortOf(current, st.reasoningEffort) : t('providerDefault'))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// open / close behaviour
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
if (dir?.status !== 'ready') load();
|
||||||
|
const onDown = (e) => {
|
||||||
|
for (const r of [rootRef, menuRef, subRef]) if (r.current?.contains(e.target)) return;
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') { setOpen(false); trigRef.current?.focus(); } };
|
||||||
|
document.addEventListener('mousedown', onDown);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); };
|
||||||
|
}, [open]);
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!open) { setPos(null); setHover(null); return; }
|
||||||
|
if (menuRef.current && trigRef.current) setPos(place(trigRef.current, menuRef.current));
|
||||||
|
}, [open, groups.length, dir?.status]);
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!hover) { setSubPos(null); return; }
|
||||||
|
if (subRef.current && hover.el) setSubPos(place(hover.el, subRef.current, { side: 'right' }));
|
||||||
|
}, [hover]);
|
||||||
|
|
||||||
|
const choose = async (patch) => {
|
||||||
|
setErr(null);
|
||||||
|
setOpen(false);
|
||||||
|
try { await settings.save(patch); } catch (e) { setErr(String(e.message || e)); setOpen(true); }
|
||||||
|
};
|
||||||
|
const pickModel = (group, model, effort) => choose({
|
||||||
|
enabled: true, provider: group.id, model: model.id,
|
||||||
|
reasoningEffort: effort === undefined ? (model.reasoning?.defaultEffort ?? null) : effort,
|
||||||
|
});
|
||||||
|
|
||||||
|
const enterRow = (group, model, el) => {
|
||||||
|
clearTimeout(hoverTimer.current);
|
||||||
|
if (!model.reasoning?.efforts?.length) { setHover(null); return; }
|
||||||
|
hoverTimer.current = setTimeout(() => setHover({ key: `${group.id}/${model.id}`, el, group, model }), 120);
|
||||||
|
};
|
||||||
|
const leaveRow = () => { clearTimeout(hoverTimer.current); };
|
||||||
|
|
||||||
|
const check = (on) => h('span', { className: 'oadv-check' }, on ? h(IconCheck, { size: 14 }) : null);
|
||||||
|
|
||||||
|
const menu = open ? ReactDOM.createPortal(h('div', {
|
||||||
|
ref: menuRef, className: 'oadv-menu', role: 'menu', 'aria-label': t('title'),
|
||||||
|
style: pos ? { left: pos.left, top: pos.top } : { left: -9999, top: -9999, visibility: 'hidden' },
|
||||||
|
onMouseLeave: () => { clearTimeout(hoverTimer.current); },
|
||||||
|
},
|
||||||
|
h('div', { className: 'oadv-head' }, t('title'), ' · ', t('hint')),
|
||||||
|
err ? h('div', { className: 'oadv-err' }, fill(t('error'), { msg: err })) : null,
|
||||||
|
h('button', { type: 'button', role: 'menuitemradio', 'aria-checked': !enabled, className: 'oadv-item', onMouseEnter: () => setHover(null), onClick: () => choose({ enabled: false }) },
|
||||||
|
check(!enabled), h('span', { className: 'oadv-item-name' }, t('offItem'))),
|
||||||
|
h('button', { type: 'button', role: 'menuitemradio', 'aria-checked': enabled && !own, className: 'oadv-item', onMouseEnter: () => setHover(null), onClick: () => choose({ enabled: true, provider: '', model: '', reasoningEffort: null }) },
|
||||||
|
check(enabled && !own), h('span', { className: 'oadv-item-name' }, t('sameItem'))),
|
||||||
|
h('div', { className: 'oadv-sep' }),
|
||||||
|
dir?.status === 'loading' && !groups.length ? h('div', { className: 'oadv-status' }, t('loading')) : null,
|
||||||
|
...groups.flatMap((group) => [
|
||||||
|
h('div', { key: `g:${group.id}`, className: 'oadv-group' }, group.name ?? group.id),
|
||||||
|
...group.models.map((model) => {
|
||||||
|
const key = `${group.id}/${model.id}`;
|
||||||
|
const selected = enabled && own && st.provider === group.id && st.model === model.id;
|
||||||
|
const hasEfforts = !!model.reasoning?.efforts?.length;
|
||||||
|
return h('button', {
|
||||||
|
key, type: 'button', role: 'menuitemradio', 'aria-checked': selected, 'aria-haspopup': hasEfforts ? 'menu' : undefined,
|
||||||
|
className: 'oadv-item', 'data-active': hover?.key === key ? '' : undefined,
|
||||||
|
onMouseEnter: (e) => enterRow(group, model, e.currentTarget),
|
||||||
|
onMouseLeave: leaveRow,
|
||||||
|
onFocus: (e) => enterRow(group, model, e.currentTarget),
|
||||||
|
onKeyDown: (e) => { if (e.key === 'ArrowRight' && hasEfforts) { setHover({ key, el: e.currentTarget, group, model }); setTimeout(() => subRef.current?.querySelector('button')?.focus(), 0); } },
|
||||||
|
onClick: () => pickModel(group, model),
|
||||||
|
disabled: model.available === false,
|
||||||
|
},
|
||||||
|
check(selected),
|
||||||
|
h('span', { className: 'oadv-item-name' }, model.name ?? model.id),
|
||||||
|
selected && st.reasoningEffort ? h('span', { className: 'oadv-item-meta' }, effortOf(model, st.reasoningEffort)) : null,
|
||||||
|
hasEfforts ? h(IconChevronRight, { size: 12 }) : null);
|
||||||
|
}),
|
||||||
|
])), document.body) : null;
|
||||||
|
|
||||||
|
const sub = open && hover ? ReactDOM.createPortal(h('div', {
|
||||||
|
ref: subRef, className: 'oadv-sub', role: 'menu', 'aria-label': t('effort'),
|
||||||
|
style: subPos ? { left: subPos.left, top: subPos.top } : { left: -9999, top: -9999, visibility: 'hidden' },
|
||||||
|
onMouseEnter: () => clearTimeout(hoverTimer.current),
|
||||||
|
onKeyDown: (e) => { if (e.key === 'ArrowLeft') { hover.el?.focus(); setHover(null); } },
|
||||||
|
},
|
||||||
|
h('div', { className: 'oadv-head' }, hover.model.name ?? hover.model.id, ' · ', t('effort')),
|
||||||
|
...(hover.model.reasoning.defaultEffort === undefined ? [h('button', {
|
||||||
|
key: 'def', type: 'button', role: 'menuitemradio', className: 'oadv-item',
|
||||||
|
'aria-checked': enabled && own && st.provider === hover.group.id && st.model === hover.model.id && !st.reasoningEffort,
|
||||||
|
onClick: () => pickModel(hover.group, hover.model, null),
|
||||||
|
}, check(enabled && own && st.provider === hover.group.id && st.model === hover.model.id && !st.reasoningEffort), h('span', { className: 'oadv-item-name' }, t('providerDefault')))] : []),
|
||||||
|
...hover.model.reasoning.efforts.map((e) => {
|
||||||
|
const on = enabled && own && st.provider === hover.group.id && st.model === hover.model.id
|
||||||
|
&& (st.reasoningEffort ?? hover.model.reasoning.defaultEffort) === e.id;
|
||||||
|
return h('button', { key: e.id, type: 'button', role: 'menuitemradio', 'aria-checked': on, className: 'oadv-item', onClick: () => pickModel(hover.group, hover.model, e.id) },
|
||||||
|
check(on), h('span', { className: 'oadv-item-name' }, effortName(e)),
|
||||||
|
e.id === hover.model.reasoning.defaultEffort ? h('span', { className: 'oadv-item-meta' }, t('providerDefault')) : null);
|
||||||
|
})), document.body) : null;
|
||||||
|
|
||||||
|
return h('div', { ref: rootRef, className: 'oadv-root' },
|
||||||
|
h('button', {
|
||||||
|
ref: trigRef, type: 'button', className: 'oadv-trigger', 'data-off': enabled ? undefined : '',
|
||||||
|
'aria-haspopup': 'menu', 'aria-expanded': open, title: t('title'),
|
||||||
|
onClick: () => setOpen((v) => !v),
|
||||||
|
},
|
||||||
|
h(IconAdvisor, { size: 14 }),
|
||||||
|
h('span', { className: 'oadv-label' }, label),
|
||||||
|
effortLabel ? h('span', { className: 'oadv-effort' }, effortLabel) : null,
|
||||||
|
h(IconChevronDown, { size: 12 })),
|
||||||
|
menu, sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
inject: ['slots', 'locale', 'sessions', 'modelDirectories'],
|
||||||
|
apply(ctx) {
|
||||||
|
ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'omp-advisor: dictionaries');
|
||||||
|
ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
|
||||||
|
name: 'conversation.input.right',
|
||||||
|
id: 'omp-advisor',
|
||||||
|
order: 50,
|
||||||
|
locale: NS,
|
||||||
|
inject: (sessionId) => {
|
||||||
|
const directory = ctx.modelDirectories.directoryFor(sessionId);
|
||||||
|
// Read-only use of the directory: the advisor never calls directory.select (that would
|
||||||
|
// switch the agent's own model).
|
||||||
|
return { directory: directory.store, load: () => { directory.load().catch(() => {}); } };
|
||||||
|
},
|
||||||
|
}, AdvisorPicker));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -10,11 +10,26 @@
|
|||||||
* still-unacknowledged blocker steers the agent into one more step.
|
* still-unacknowledged blocker steers the agent into one more step.
|
||||||
* Reviews run in the background and never delay the main loop unless `blocking: true`.
|
* Reviews run in the background and never delay the main loop unless `blocking: true`.
|
||||||
*/
|
*/
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||||
|
|
||||||
export const name = 'omp-advisor'
|
export const name = 'omp-advisor'
|
||||||
export const inject = ['agents', 'llm']
|
export const inject = ['agents', 'llm']
|
||||||
|
|
||||||
|
/** Live settings chosen in the UI (enabled + advisor route), persisted next to the profile. */
|
||||||
|
function settingsFile() {
|
||||||
|
const dir = process.env.DSH_PROFILE_DIR || path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'profiles', 'web')
|
||||||
|
return path.join(dir, 'omp-advisor.json')
|
||||||
|
}
|
||||||
|
function loadSettings() { try { return JSON.parse(fs.readFileSync(settingsFile(), 'utf8')) } catch { return {} } }
|
||||||
|
function saveSettings(v) {
|
||||||
|
const f = settingsFile()
|
||||||
|
fs.mkdirSync(path.dirname(f), { recursive: true })
|
||||||
|
fs.writeFileSync(f, JSON.stringify(v, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
const SOURCE = { kind: 'omp-advisor' }
|
const SOURCE = { kind: 'omp-advisor' }
|
||||||
|
|
||||||
const SYSTEM = `You are the ADVISOR: a senior engineer silently pairing with a coding agent. You see a digest of the conversation and the agent's latest step (its message, tool calls and results). Intervene only when it matters:
|
const SYSTEM = `You are the ADVISOR: a senior engineer silently pairing with a coding agent. You see a digest of the conversation and the agent's latest step (its message, tool calls and results). Intervene only when it matters:
|
||||||
@@ -40,11 +55,18 @@ export function apply(ctx, config = {}) {
|
|||||||
...config,
|
...config,
|
||||||
}
|
}
|
||||||
const LEVEL = { ok: 0, note: 1, concern: 2, blocker: 3 }
|
const LEVEL = { ok: 0, note: 1, concern: 2, blocker: 3 }
|
||||||
const minLevel = LEVEL[cfg.minLevel] ?? 1
|
let live = loadSettings() // { enabled?, provider?, model?, reasoningEffort? }
|
||||||
|
const eff = () => ({
|
||||||
|
enabled: live.enabled ?? cfg.enabled,
|
||||||
|
provider: live.provider ?? cfg.provider,
|
||||||
|
model: live.model ?? cfg.model,
|
||||||
|
reasoningEffort: live.reasoningEffort === null ? undefined : (live.reasoningEffort ?? cfg.reasoningEffort),
|
||||||
|
})
|
||||||
|
const minLevel = () => LEVEL[live.minLevel ?? cfg.minLevel] ?? 1
|
||||||
const installed = new Map()
|
const installed = new Map()
|
||||||
|
|
||||||
function install(agent) {
|
function install(agent) {
|
||||||
if (installed.has(agent) || !agent?.ctx || !cfg.enabled) return
|
if (installed.has(agent) || !agent?.ctx) return
|
||||||
if (cfg.skipSubagents && agent.session?.header?.parentSession) return
|
if (cfg.skipSubagents && agent.session?.header?.parentSession) return
|
||||||
const st = { pending: null, queue: [], history: [], step: 0, openBlocker: null, lastReviewedSeq: -1, failures: 0 }
|
const st = { pending: null, queue: [], history: [], step: 0, openBlocker: null, lastReviewedSeq: -1, failures: 0 }
|
||||||
const disposers = []
|
const disposers = []
|
||||||
@@ -53,6 +75,7 @@ export function apply(ctx, config = {}) {
|
|||||||
disposers.push(agent.ctx.on('agent/pre-step', async (payload, next) => {
|
disposers.push(agent.ctx.on('agent/pre-step', async (payload, next) => {
|
||||||
const decision = await next()
|
const decision = await next()
|
||||||
if (decision.kind !== 'enter') return decision
|
if (decision.kind !== 'enter') return decision
|
||||||
|
if (!eff().enabled) { st.queue.length = 0; return decision }
|
||||||
st.step++
|
st.step++
|
||||||
if (payload.step > 1 && st.step % cfg.everyNSteps === 0 && !st.pending) {
|
if (payload.step > 1 && st.step % cfg.everyNSteps === 0 && !st.pending) {
|
||||||
st.pending = review(agent, st, payload.signal).finally(() => { st.pending = null })
|
st.pending = review(agent, st, payload.signal).finally(() => { st.pending = null })
|
||||||
@@ -66,6 +89,7 @@ export function apply(ctx, config = {}) {
|
|||||||
|
|
||||||
// At the end of a turn: deliver late advice; a blocker forces one more step.
|
// At the end of a turn: deliver late advice; a blocker forces one more step.
|
||||||
disposers.push(agent.ctx.on('agent/turn-stopping', async ({ signal }) => {
|
disposers.push(agent.ctx.on('agent/turn-stopping', async ({ signal }) => {
|
||||||
|
if (!eff().enabled) return
|
||||||
if (st.pending) {
|
if (st.pending) {
|
||||||
let t
|
let t
|
||||||
await Promise.race([st.pending, new Promise((r) => { t = setTimeout(r, cfg.blocking ? cfg.timeoutMs : 4000) })]).finally(() => clearTimeout(t))
|
await Promise.race([st.pending, new Promise((r) => { t = setTimeout(r, cfg.blocking ? cfg.timeoutMs : 4000) })]).finally(() => clearTimeout(t))
|
||||||
@@ -90,8 +114,11 @@ export function apply(ctx, config = {}) {
|
|||||||
async function review(agent, st, signal) {
|
async function review(agent, st, signal) {
|
||||||
try {
|
try {
|
||||||
const header = agent.session.requestHeader?.()
|
const header = agent.session.requestHeader?.()
|
||||||
const provider = cfg.provider || header?.config?.provider
|
const e = eff()
|
||||||
const model = cfg.model || header?.config?.model
|
// A half-set route (provider without model) falls back to the agent's model as a whole.
|
||||||
|
const own = e.provider && e.model
|
||||||
|
const provider = own ? e.provider : header?.config?.provider
|
||||||
|
const model = own ? e.model : header?.config?.model
|
||||||
if (!provider || !model) return
|
if (!provider || !model) return
|
||||||
const digest = buildDigest(agent.session, st)
|
const digest = buildDigest(agent.session, st)
|
||||||
if (!digest) return
|
if (!digest) return
|
||||||
@@ -103,7 +130,7 @@ export function apply(ctx, config = {}) {
|
|||||||
try {
|
try {
|
||||||
for await (const ch of ctx.llm.stream({
|
for await (const ch of ctx.llm.stream({
|
||||||
provider, model,
|
provider, model,
|
||||||
...(cfg.reasoningEffort ? { reasoningEffort: cfg.reasoningEffort } : {}),
|
...(own && e.reasoningEffort ? { reasoningEffort: e.reasoningEffort } : {}),
|
||||||
system: SYSTEM,
|
system: SYSTEM,
|
||||||
messages: [createUserMessage({ content: [{ type: 'text', text: `PREVIOUS ADVICE:\n${prev}\n\nTRANSCRIPT DIGEST (oldest first, latest step last):\n${digest}\n\nYour JSON verdict:` }], source: SOURCE })],
|
messages: [createUserMessage({ content: [{ type: 'text', text: `PREVIOUS ADVICE:\n${prev}\n\nTRANSCRIPT DIGEST (oldest first, latest step last):\n${digest}\n\nYour JSON verdict:` }], source: SOURCE })],
|
||||||
maxTokens: 600,
|
maxTokens: 600,
|
||||||
@@ -116,7 +143,7 @@ export function apply(ctx, config = {}) {
|
|||||||
const v = parseVerdict(text)
|
const v = parseVerdict(text)
|
||||||
if (!v) return
|
if (!v) return
|
||||||
st.history.push(v)
|
st.history.push(v)
|
||||||
if ((LEVEL[v.verdict] ?? 0) >= minLevel && LEVEL[v.verdict] > 0) {
|
if ((LEVEL[v.verdict] ?? 0) >= minLevel() && LEVEL[v.verdict] > 0) {
|
||||||
if (st.history.slice(0, -1).some((h) => h.message === v.message)) return
|
if (st.history.slice(0, -1).some((h) => h.message === v.message)) return
|
||||||
st.queue.push(v)
|
st.queue.push(v)
|
||||||
}
|
}
|
||||||
@@ -163,6 +190,38 @@ export function apply(ctx, config = {}) {
|
|||||||
const contentText = (c) => (Array.isArray(c) ? c.map((x) => (x.type === 'text' ? x.text : `[${x.type}]`)).join('\n') : String(c ?? ''))
|
const contentText = (c) => (Array.isArray(c) ? c.map((x) => (x.type === 'text' ? x.text : `[${x.type}]`)).join('\n') : String(c ?? ''))
|
||||||
const clip = (s, n) => { const t = String(s ?? ''); return t.length > n ? `${t.slice(0, n)}…[+${t.length - n} chars]` : t }
|
const clip = (s, n) => { const t = String(s ?? ''); return t.length > n ? `${t.slice(0, n)}…[+${t.length - n} chars]` : t }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Web UI settings routes
|
||||||
|
// Mounted only while the web server and its trust fence exist (a headless profile has neither).
|
||||||
|
ctx.inject?.(['webServer', 'connection'], (wctx) => {
|
||||||
|
const webServer = wctx.webServer
|
||||||
|
const connection = wctx.connection
|
||||||
|
const send = (res, code, body) => { res.statusCode = code; res.setHeader('content-type', 'application/json; charset=utf-8'); res.setHeader('cache-control', 'no-store'); res.end(JSON.stringify(body)) }
|
||||||
|
const readBody = async (req) => { let b = ''; for await (const c of req) { b += c; if (b.length > 16384) throw new Error('body too large') } return b ? JSON.parse(b) : {} }
|
||||||
|
const state = () => ({ ...eff(), configured: { provider: cfg.provider || null, model: cfg.model || null }, minLevel: live.minLevel ?? cfg.minLevel })
|
||||||
|
wctx.effect(() => webServer.register({
|
||||||
|
kind: 'exact',
|
||||||
|
path: '/omp-advisor/settings',
|
||||||
|
handler: async (req, res) => {
|
||||||
|
const rej = connection.requestRejection(req)
|
||||||
|
if (rej !== undefined) return send(res, rej, { code: 'rejected' })
|
||||||
|
try {
|
||||||
|
if (req.method === 'GET') return send(res, 200, state())
|
||||||
|
if (req.method !== 'POST') { res.statusCode = 405; return res.end() }
|
||||||
|
const b = await readBody(req)
|
||||||
|
const next = { ...live }
|
||||||
|
if (typeof b.enabled === 'boolean') next.enabled = b.enabled
|
||||||
|
if ('provider' in b) next.provider = typeof b.provider === 'string' && b.provider ? b.provider : ''
|
||||||
|
if ('model' in b) next.model = typeof b.model === 'string' && b.model ? b.model : ''
|
||||||
|
if ('reasoningEffort' in b) next.reasoningEffort = typeof b.reasoningEffort === 'string' && b.reasoningEffort ? b.reasoningEffort : null
|
||||||
|
if (typeof b.minLevel === 'string' && b.minLevel in LEVEL && b.minLevel !== 'ok') next.minLevel = b.minLevel
|
||||||
|
live = next
|
||||||
|
saveSettings(live)
|
||||||
|
return send(res, 200, state())
|
||||||
|
} catch (e) { return send(res, 400, { code: 'error', message: String(e?.message ?? e) }) }
|
||||||
|
},
|
||||||
|
}), 'omp-advisor: settings route')
|
||||||
|
})
|
||||||
|
|
||||||
for (const agent of ctx.agents.list()) install(agent)
|
for (const agent of ctx.agents.list()) install(agent)
|
||||||
ctx.on('agent/created', ({ agent }) => { install(agent) })
|
ctx.on('agent/created', ({ agent }) => { install(agent) })
|
||||||
ctx.on('agent/disposed', ({ agent }) => { installed.get(agent)?.(); installed.delete(agent) })
|
ctx.on('agent/disposed', ({ agent }) => { installed.get(agent)?.(); installed.delete(agent) })
|
||||||
|
|||||||
+15
-4
@@ -1,12 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "@local/omp-advisor",
|
"name": "@local/omp-advisor",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "oh-my-pi style advisor for DSH: a second model reviews every agent step and injects notes, concerns or blockers.",
|
"description": "oh-my-pi style advisor for DSH: a second model reviews every agent step and injects notes, concerns or blockers.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"exports": { ".": "./index.js", "./package.json": "./package.json", "./locale/*.json": "./locale/*.json" },
|
"exports": { ".": "./index.js", "./client": "./client.js", "./package.json": "./package.json", "./locale/*.json": "./locale/*.json" },
|
||||||
"files": ["index.js", "cordis.patch.yml", "locale/*.json"],
|
"files": ["index.js", "client.js", "cordis.patch.yml", "locale/*.json"],
|
||||||
"scripts": { "test": "node --test test/*.test.mjs" },
|
"scripts": { "test": "node --test test/*.test.mjs" },
|
||||||
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
|
"dsh": {
|
||||||
|
"bundle": { "patch": "./cordis.patch.yml" },
|
||||||
|
"client": {
|
||||||
|
"platform": "web",
|
||||||
|
"immediately": true,
|
||||||
|
"inject": [
|
||||||
|
"@deepseek-ai/dsh-client-locale",
|
||||||
|
"@deepseek-ai/dsh-client-ui-conversation",
|
||||||
|
"@deepseek-ai/dsh-client-ui-model-selection"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+61
-2
@@ -1,8 +1,19 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { Readable } from 'node:stream'
|
||||||
import * as plugin from '../index.js'
|
import * as plugin from '../index.js'
|
||||||
|
|
||||||
|
// Settings persist to <DSH_PROFILE_DIR>/omp-advisor.json; keep tests away from the real profile.
|
||||||
|
const PROFILE = fs.mkdtempSync(path.join(os.tmpdir(), 'omp-advisor-test-'))
|
||||||
|
process.env.DSH_PROFILE_DIR = PROFILE
|
||||||
|
const SETTINGS = path.join(PROFILE, 'omp-advisor.json')
|
||||||
|
test.after(() => fs.rmSync(PROFILE, { recursive: true, force: true }))
|
||||||
|
|
||||||
function harness(replies, config = {}) {
|
function harness(replies, config = {}) {
|
||||||
|
fs.rmSync(SETTINGS, { force: true }) // live settings load in apply(): never leak between tests
|
||||||
const listeners = new Map()
|
const listeners = new Map()
|
||||||
const on = (n, f) => { listeners.set(n, [...(listeners.get(n) ?? []), f]); return () => listeners.set(n, listeners.get(n).filter((x) => x !== f)) }
|
const on = (n, f) => { listeners.set(n, [...(listeners.get(n) ?? []), f]); return () => listeners.set(n, listeners.get(n).filter((x) => x !== f)) }
|
||||||
const prompts = []
|
const prompts = []
|
||||||
@@ -24,7 +35,13 @@ function harness(replies, config = {}) {
|
|||||||
}
|
}
|
||||||
const steered = []
|
const steered = []
|
||||||
const agent = { session, ctx: { on }, steer: (m) => steered.push(m) }
|
const agent = { session, ctx: { on }, steer: (m) => steered.push(m) }
|
||||||
const ctx = { agents: { list: () => [] }, llm, on, effect() {} }
|
const routes = new Map()
|
||||||
|
const webServer = { register: (r) => { routes.set(r.path, r.handler); return () => routes.delete(r.path) } }
|
||||||
|
const connection = { requestRejection: (req) => (req.headers?.['x-reject'] ? 401 : undefined) }
|
||||||
|
const ctx = {
|
||||||
|
agents: { list: () => [] }, llm, on, effect(fn) { return typeof fn === 'function' ? fn() : undefined },
|
||||||
|
inject: (_deps, fn) => fn({ webServer, connection, effect: (f) => f() }),
|
||||||
|
}
|
||||||
plugin.apply(ctx, { minLevel: 'note', blocking: true, ...config })
|
plugin.apply(ctx, { minLevel: 'note', blocking: true, ...config })
|
||||||
for (const f of listeners.get('agent/created') ?? []) f({ agent })
|
for (const f of listeners.get('agent/created') ?? []) f({ agent })
|
||||||
const preStep = async (step) => {
|
const preStep = async (step) => {
|
||||||
@@ -34,7 +51,18 @@ function harness(replies, config = {}) {
|
|||||||
}
|
}
|
||||||
const stopTurn = async () => { for (const f of listeners.get('agent/turn-stopping') ?? []) await f({ agent, turn: 1 }) }
|
const stopTurn = async () => { for (const f of listeners.get('agent/turn-stopping') ?? []) await f({ agent, turn: 1 }) }
|
||||||
const add = (role, content) => events.push({ msg: { role, content } })
|
const add = (role, content) => events.push({ msg: { role, content } })
|
||||||
return { preStep, stopTurn, add, steered, prompts }
|
/** Call the settings route like the web client does. */
|
||||||
|
const http = async (method, body, headers = {}) => {
|
||||||
|
const handler = routes.get('/omp-advisor/settings')
|
||||||
|
const req = Readable.from(body === undefined ? [] : [JSON.stringify(body)])
|
||||||
|
req.method = method
|
||||||
|
req.headers = headers
|
||||||
|
let status = 200; let out = ''
|
||||||
|
const res = { statusCode: 200, setHeader() {}, end: (s) => { status = res.statusCode; out = s ?? '' } }
|
||||||
|
await handler(req, res)
|
||||||
|
return { status, json: out ? JSON.parse(out) : null }
|
||||||
|
}
|
||||||
|
return { preStep, stopTurn, add, steered, prompts, http }
|
||||||
}
|
}
|
||||||
|
|
||||||
test('parseVerdict', () => {
|
test('parseVerdict', () => {
|
||||||
@@ -82,3 +110,34 @@ test('minLevel filters notes and duplicates are suppressed', async () => {
|
|||||||
assert.equal((await h.preStep(3)).messages.length, 1) // concern (blocking review) delivered
|
assert.equal((await h.preStep(3)).messages.length, 1) // concern (blocking review) delivered
|
||||||
assert.equal((await h.preStep(4)).messages.length, 0) // identical concern suppressed
|
assert.equal((await h.preStep(4)).messages.length, 0) // identical concern suppressed
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('settings route: GET/POST, rejection, persistence', async () => {
|
||||||
|
fs.rmSync(SETTINGS, { force: true })
|
||||||
|
const h = harness([])
|
||||||
|
assert.equal((await h.http('GET', undefined, { 'x-reject': '1' })).status, 401)
|
||||||
|
const g = await h.http('GET')
|
||||||
|
assert.equal(g.status, 200)
|
||||||
|
assert.equal(g.json.enabled, true)
|
||||||
|
const p = await h.http('POST', { enabled: true, provider: 'omni', model: 'cheap-model', reasoningEffort: 'low' })
|
||||||
|
assert.equal(p.json.model, 'cheap-model')
|
||||||
|
assert.equal(p.json.reasoningEffort, 'low')
|
||||||
|
assert.deepEqual(JSON.parse(fs.readFileSync(SETTINGS, 'utf8')), { enabled: true, provider: 'omni', model: 'cheap-model', reasoningEffort: 'low' })
|
||||||
|
assert.equal((await h.http('PUT', {})).status, 405)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('UI choice applies on the next review without restart; off disables reviews', async () => {
|
||||||
|
fs.rmSync(SETTINGS, { force: true })
|
||||||
|
const h = harness(['{"verdict":"ok","message":""}', '{"verdict":"ok","message":""}'])
|
||||||
|
h.add('user', [{ type: 'text', text: 'task' }])
|
||||||
|
await h.http('POST', { enabled: true, provider: 'omni', model: 'cheap-model', reasoningEffort: 'low' })
|
||||||
|
await h.preStep(2)
|
||||||
|
assert.equal(h.prompts[0].model, 'cheap-model')
|
||||||
|
assert.equal(h.prompts[0].reasoningEffort, 'low')
|
||||||
|
await h.http('POST', { provider: '', model: '', reasoningEffort: null }) // same as agent
|
||||||
|
await h.preStep(3)
|
||||||
|
assert.equal(h.prompts[1].model, 'main-model')
|
||||||
|
assert.equal(h.prompts[1].reasoningEffort, undefined)
|
||||||
|
await h.http('POST', { enabled: false })
|
||||||
|
await h.preStep(4)
|
||||||
|
assert.equal(h.prompts.length, 2) // no review while off
|
||||||
|
})
|
||||||
Reference in new issue
Block a user