v1.2.0: advisor is per session and off by default (new and existing sessions); old global setting ignored

This commit is contained in:
Agent committed 2026-10-11 05:16:05 +00:00
1 parent f85f445ef6
commit 0f423e3699
6 files changed
+152 -55

No files matched your search

+53 -24
View File
@@ -18,17 +18,36 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
export const name = 'omp-advisor'
export const inject = ['agents', 'llm']
/** Live settings chosen in the UI (enabled + advisor route), persisted next to the profile. */
/**
* Per-session advisor choices made in the UI: { [sessionId]: { enabled, provider, model,
* reasoningEffort, minLevel, at } }. A session without an entry uses the config defaults, which
* are OFF. The pre-1.2 global file (`omp-advisor.json`) is intentionally ignored so that no
* existing session silently stays on.
*/
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')
return path.join(dir, 'omp-advisor', 'sessions.json')
}
function loadSettings() { try { return JSON.parse(fs.readFileSync(settingsFile(), 'utf8')) } catch { return {} } }
function saveSettings(v) {
const MAX_SESSIONS = 2000
function loadSessions() {
try {
const v = JSON.parse(fs.readFileSync(settingsFile(), 'utf8'))
return v && typeof v === 'object' && !Array.isArray(v) ? v : {}
} catch { return {} }
}
function saveSessions(all) {
const f = settingsFile()
fs.mkdirSync(path.dirname(f), { recursive: true })
fs.writeFileSync(f, JSON.stringify(v, null, 2))
// Keep the most recently changed sessions only.
const entries = Object.entries(all)
const kept = entries.length > MAX_SESSIONS
? Object.fromEntries(entries.sort((a, b) => (b[1]?.at ?? 0) - (a[1]?.at ?? 0)).slice(0, MAX_SESSIONS))
: all
const tmp = `${f}.${process.pid}.tmp`
fs.writeFileSync(tmp, JSON.stringify(kept, null, 1))
fs.renameSync(tmp, f) // atomic replace: a crash never leaves half a file
}
const validSessionId = (s) => typeof s === 'string' && s.length > 0 && s.length <= 200 && /^[\w.:-]+$/.test(s)
const SOURCE = { kind: 'omp-advisor' }
@@ -41,7 +60,7 @@ Be specific and short (max 3 sentences), reference files/commands concretely, an
export function apply(ctx, config = {}) {
const cfg = {
enabled: true,
enabled: false, // default for every session until it is turned on in the composer
provider: '', // empty = same provider as the agent
model: '', // empty = same model as the agent
reasoningEffort: undefined,
@@ -55,14 +74,20 @@ export function apply(ctx, config = {}) {
...config,
}
const LEVEL = { ok: 0, note: 1, concern: 2, blocker: 3 }
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
let sessions = loadSessions()
const sessionIdOf = (agent) => agent?.id ?? agent?.session?.header?.id
/** Effective settings for one session: its own UI choice over the config defaults. */
const eff = (sessionId) => {
const live = (sessionId && sessions[sessionId]) || {}
return {
enabled: live.enabled ?? cfg.enabled,
provider: live.provider ?? cfg.provider,
model: live.model ?? cfg.model,
reasoningEffort: live.reasoningEffort === null ? undefined : (live.reasoningEffort ?? cfg.reasoningEffort),
minLevel: live.minLevel ?? cfg.minLevel,
}
}
const minLevel = (sessionId) => LEVEL[eff(sessionId).minLevel] ?? 1
const installed = new Map()
function install(agent) {
@@ -75,7 +100,7 @@ export function apply(ctx, config = {}) {
disposers.push(agent.ctx.on('agent/pre-step', async (payload, next) => {
const decision = await next()
if (decision.kind !== 'enter') return decision
if (!eff().enabled) { st.queue.length = 0; return decision }
if (!eff(sessionIdOf(agent)).enabled) { st.queue.length = 0; return decision }
st.step++
if (payload.step > 1 && st.step % cfg.everyNSteps === 0 && !st.pending) {
st.pending = review(agent, st, payload.signal).finally(() => { st.pending = null })
@@ -89,7 +114,7 @@ export function apply(ctx, config = {}) {
// 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 }) => {
if (!eff().enabled) return
if (!eff(sessionIdOf(agent)).enabled) return
if (st.pending) {
let t
await Promise.race([st.pending, new Promise((r) => { t = setTimeout(r, cfg.blocking ? cfg.timeoutMs : 4000) })]).finally(() => clearTimeout(t))
@@ -114,7 +139,7 @@ export function apply(ctx, config = {}) {
async function review(agent, st, signal) {
try {
const header = agent.session.requestHeader?.()
const e = eff()
const e = eff(sessionIdOf(agent))
// 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
@@ -143,7 +168,7 @@ export function apply(ctx, config = {}) {
const v = parseVerdict(text)
if (!v) return
st.history.push(v)
if ((LEVEL[v.verdict] ?? 0) >= minLevel() && LEVEL[v.verdict] > 0) {
if ((LEVEL[v.verdict] ?? 0) >= minLevel(sessionIdOf(agent)) && LEVEL[v.verdict] > 0) {
if (st.history.slice(0, -1).some((h) => h.message === v.message)) return
st.queue.push(v)
}
@@ -197,7 +222,7 @@ export function apply(ctx, config = {}) {
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 })
const state = (sessionId) => ({ sessionId, ...eff(sessionId), configured: { provider: cfg.provider || null, model: cfg.model || null, enabled: !!cfg.enabled } })
wctx.effect(() => webServer.register({
kind: 'exact',
path: '/omp-advisor/settings',
@@ -205,18 +230,22 @@ export function apply(ctx, config = {}) {
const rej = connection.requestRejection(req)
if (rej !== undefined) return send(res, rej, { code: 'rejected' })
try {
if (req.method === 'GET') return send(res, 200, state())
// Settings are per session: ?session=<id> on every call.
const sessionId = new URL(req.url ?? '/', 'http://x').searchParams.get('session')
if (!validSessionId(sessionId)) return send(res, 400, { code: 'error', message: 'session query parameter is required' })
if (req.method === 'GET') return send(res, 200, state(sessionId))
if (req.method !== 'POST') { res.statusCode = 405; return res.end() }
const b = await readBody(req)
const next = { ...live }
const next = { ...(sessions[sessionId] ?? {}) }
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())
next.at = Date.now()
sessions = { ...sessions, [sessionId]: next }
saveSessions(sessions)
return send(res, 200, state(sessionId))
} catch (e) { return send(res, 400, { code: 'error', message: String(e?.message ?? e) }) }
},
}), 'omp-advisor: settings route')