v1.2.0: advisor is per session and off by default (new and existing sessions); old global setting ignored
This commit is contained in:
1 parent
f85f445ef6
commit
0f423e3699
6 files changed
+152
-55
No files matched your search
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user