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
@@ -10,11 +10,26 @@
|
||||
* still-unacknowledged blocker steers the agent into one more step.
|
||||
* 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'
|
||||
|
||||
export const name = 'omp-advisor'
|
||||
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 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,
|
||||
}
|
||||
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()
|
||||
|
||||
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
|
||||
const st = { pending: null, queue: [], history: [], step: 0, openBlocker: null, lastReviewedSeq: -1, failures: 0 }
|
||||
const disposers = []
|
||||
@@ -53,6 +75,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 }
|
||||
st.step++
|
||||
if (payload.step > 1 && st.step % cfg.everyNSteps === 0 && !st.pending) {
|
||||
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.
|
||||
disposers.push(agent.ctx.on('agent/turn-stopping', async ({ signal }) => {
|
||||
if (!eff().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))
|
||||
@@ -90,8 +114,11 @@ export function apply(ctx, config = {}) {
|
||||
async function review(agent, st, signal) {
|
||||
try {
|
||||
const header = agent.session.requestHeader?.()
|
||||
const provider = cfg.provider || header?.config?.provider
|
||||
const model = cfg.model || header?.config?.model
|
||||
const e = eff()
|
||||
// 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
|
||||
const digest = buildDigest(agent.session, st)
|
||||
if (!digest) return
|
||||
@@ -103,7 +130,7 @@ export function apply(ctx, config = {}) {
|
||||
try {
|
||||
for await (const ch of ctx.llm.stream({
|
||||
provider, model,
|
||||
...(cfg.reasoningEffort ? { reasoningEffort: cfg.reasoningEffort } : {}),
|
||||
...(own && e.reasoningEffort ? { reasoningEffort: e.reasoningEffort } : {}),
|
||||
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 })],
|
||||
maxTokens: 600,
|
||||
@@ -116,7 +143,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() && LEVEL[v.verdict] > 0) {
|
||||
if (st.history.slice(0, -1).some((h) => h.message === v.message)) return
|
||||
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 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)
|
||||
ctx.on('agent/created', ({ agent }) => { install(agent) })
|
||||
ctx.on('agent/disposed', ({ agent }) => { installed.get(agent)?.(); installed.delete(agent) })
|
||||
|
||||
Reference in new issue
Block a user