Initial commit: omp-advisor DSH plugin (oh-my-pi port) with docs and tests
This commit is contained in:
commit
fccf5fc6d2
8 files changed
+340
No files matched your search
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
*.bak
|
||||
*.orig
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,47 @@
|
||||
# omp-advisor
|
||||
|
||||
[oh-my-pi](https://github.com/can1357/oh-my-pi)'s **advisor** for the DeepSeek Harness (DSH). A second model reads every step the main agent takes and adds notes inline: a quiet aside, a concern, or a hard blocker. It runs on its own context and can use its own model, so it catches what the main agent misses while focused on the task.
|
||||
|
||||
## How it works
|
||||
|
||||
- **When it reviews:** after each completed step (the agent's message, tool calls and tool results). It gets a compact digest of the conversation: the task at the head, the latest steps at the tail, and long tool output clipped. The digest also lists the advice it already gave, so it doesn't repeat itself.
|
||||
- **What it answers:** `{"verdict": "ok" | "note" | "concern" | "blocker", "message": "…"}`. `ok` is the common case, and nothing is shown.
|
||||
- **How advice reaches the agent:**
|
||||
- **note / concern** is appended to the agent's next request as a system reminder.
|
||||
- **blocker** gets the same reminder, marked *stop and address this*. If it arrives as the agent tries to end its turn, the advisor **steers** the agent into one more step to deal with it.
|
||||
- **Cost to the main loop:** reviews run in the background and add no latency, unless you set `blocking: true`.
|
||||
- **Subagents:** skipped by default (`skipSubagents`).
|
||||
|
||||
Blockers are for real damage or clear wrong turns: destructive commands, secret leaks, pushing broken code, ignoring explicit instructions, going in circles. Concerns cover likely bugs, wrong assumptions, unverified claims, or a much better approach.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
dsh plugin --profile web add git+https://git.rizzy.dev/DSH-Plugins/omp-advisor.git
|
||||
```
|
||||
|
||||
Or in the Web UI, go to **Settings → Plugins → Install**. For development, use `link:` to a clone. Restart DSH afterwards; new sessions are advised automatically.
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
- id: omp-advisor
|
||||
config:
|
||||
enabled: true
|
||||
provider: '' # advisor model route; empty = same as the agent
|
||||
model: '' # e.g. provider: omni, model: claude-haiku-4-5 for a cheap fast reviewer
|
||||
reasoningEffort: '' # optional effort for the advisor model
|
||||
minLevel: note # lowest verdict delivered: note | concern | blocker
|
||||
everyNSteps: 1 # review every Nth step
|
||||
blocking: false # true = wait for each review before the next request
|
||||
maxDigestChars: 24000
|
||||
maxToolResultChars: 1500
|
||||
timeoutMs: 60000
|
||||
skipSubagents: true
|
||||
```
|
||||
|
||||
To pause it, disable the plugin in **Settings → Plugins → OMP Advisor**.
|
||||
|
||||
## Tests
|
||||
|
||||
`npm test`: verdict parsing, injection, ok-silence, blocker steering, minLevel and duplicate suppression (mocked model).
|
||||
@@ -0,0 +1,12 @@
|
||||
- insert:
|
||||
- id: omp-advisor
|
||||
name: '@local/omp-advisor'
|
||||
config:
|
||||
enabled: true
|
||||
# empty = same provider/model as the agent; set a cheaper/faster reviewer here
|
||||
provider: ''
|
||||
model: ''
|
||||
minLevel: note # note | concern | blocker
|
||||
everyNSteps: 1
|
||||
blocking: false
|
||||
skipSubagents: true
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @local/omp-advisor — oh-my-pi's "advisor": a second model watches every step the main agent
|
||||
* takes and injects notes inline (an aside, a concern, or a hard blocker).
|
||||
*
|
||||
* After each completed step (tool results in) the advisor model reviews a compact digest of the
|
||||
* transcript on its own context and answers with JSON {verdict: ok|note|concern|blocker, message}.
|
||||
* Anything but "ok" is delivered to the main agent before its next request:
|
||||
* - note / concern: appended to the next step as a system reminder;
|
||||
* - blocker: the reminder demands the agent stop and address it, and at the turn boundary a
|
||||
* 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 { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
export const name = 'omp-advisor'
|
||||
export const inject = ['agents', 'llm']
|
||||
|
||||
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:
|
||||
- "blocker": the agent is about to cause or has caused real damage (data loss, destructive command, secret leak, pushing broken code, ignoring an explicit user instruction, going in circles), or is clearly heading the wrong way.
|
||||
- "concern": a likely bug, wrong assumption, missed requirement, unverified claim, or a much better approach.
|
||||
- "note": a brief useful aside the agent would want.
|
||||
- "ok": nothing worth saying (this should be the common case).
|
||||
Be specific and short (max 3 sentences), reference files/commands concretely, and never repeat advice you already gave (listed under PREVIOUS ADVICE). Reply with ONLY one JSON object: {"verdict":"ok|note|concern|blocker","message":"..."}`
|
||||
|
||||
export function apply(ctx, config = {}) {
|
||||
const cfg = {
|
||||
enabled: true,
|
||||
provider: '', // empty = same provider as the agent
|
||||
model: '', // empty = same model as the agent
|
||||
reasoningEffort: undefined,
|
||||
minLevel: 'note', // lowest verdict delivered: note | concern | blocker
|
||||
everyNSteps: 1,
|
||||
maxDigestChars: 24000,
|
||||
maxToolResultChars: 1500,
|
||||
blocking: false, // true: wait for the review before the next step
|
||||
timeoutMs: 60000,
|
||||
skipSubagents: true,
|
||||
...config,
|
||||
}
|
||||
const LEVEL = { ok: 0, note: 1, concern: 2, blocker: 3 }
|
||||
const minLevel = LEVEL[cfg.minLevel] ?? 1
|
||||
const installed = new Map()
|
||||
|
||||
function install(agent) {
|
||||
if (installed.has(agent) || !agent?.ctx || !cfg.enabled) 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 = []
|
||||
|
||||
// Review after every step that ran tools: the tool results are the newest surface nodes.
|
||||
disposers.push(agent.ctx.on('agent/pre-step', async (payload, next) => {
|
||||
const decision = await next()
|
||||
if (decision.kind !== 'enter') 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 })
|
||||
}
|
||||
if (cfg.blocking && st.pending) await st.pending
|
||||
const notes = st.queue.splice(0)
|
||||
if (!notes.length) return decision
|
||||
const text = notes.map(formatAdvice).join('\n\n')
|
||||
return { ...decision, messages: [...decision.messages, createUserMessage({ content: [{ type: 'text', text }], source: SOURCE })] }
|
||||
}))
|
||||
|
||||
// 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 (st.pending) await Promise.race([st.pending, new Promise((r) => setTimeout(r, cfg.blocking ? cfg.timeoutMs : 4000))])
|
||||
const notes = st.queue.splice(0)
|
||||
const block = notes.find((n) => n.verdict === 'blocker')
|
||||
if (block && !signal?.aborted) {
|
||||
agent.steer(createUserMessage({ content: [{ type: 'text', text: notes.map(formatAdvice).join('\n\n') }], source: SOURCE }))
|
||||
} else if (notes.length) {
|
||||
st.queue.push(...notes) // deliver with the next user turn
|
||||
}
|
||||
}))
|
||||
|
||||
installed.set(agent, () => { for (const d of disposers) { try { d() } catch {} } })
|
||||
}
|
||||
|
||||
function formatAdvice(n) {
|
||||
const tag = n.verdict === 'blocker' ? 'ADVISOR BLOCKER — stop and address this before continuing' : `Advisor ${n.verdict}`
|
||||
return `<system-reminder>\n[${tag}] ${n.message}\n(Second-model review of your last step. Acknowledge briefly and act on it if it is right; say why if you disagree.)\n</system-reminder>`
|
||||
}
|
||||
|
||||
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
|
||||
if (!provider || !model) return
|
||||
const digest = buildDigest(agent.session, st)
|
||||
if (!digest) return
|
||||
const prev = st.history.slice(-8).map((h) => `- [${h.verdict}] ${h.message}`).join('\n') || '(none)'
|
||||
const ac = new AbortController()
|
||||
const timer = setTimeout(() => ac.abort(), cfg.timeoutMs)
|
||||
signal?.addEventListener?.('abort', () => ac.abort(), { once: true })
|
||||
let text = ''
|
||||
try {
|
||||
for await (const ch of ctx.llm.stream({
|
||||
provider, model,
|
||||
...(cfg.reasoningEffort ? { reasoningEffort: cfg.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,
|
||||
signal: ac.signal,
|
||||
})) {
|
||||
if (ch.type === 'text-delta') text += ch.text
|
||||
if (ch.type === 'finish' && ch.reason?.kind === 'error') throw new Error(ch.reason.error?.message ?? 'advisor model error')
|
||||
}
|
||||
} finally { clearTimeout(timer) }
|
||||
const v = parseVerdict(text)
|
||||
if (!v) return
|
||||
st.history.push(v)
|
||||
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)
|
||||
}
|
||||
st.failures = 0
|
||||
} catch (e) {
|
||||
if (++st.failures <= 3) console.warn(`[omp-advisor] review failed: ${e?.message ?? e}`)
|
||||
}
|
||||
}
|
||||
|
||||
function buildDigest(session, st) {
|
||||
const nodes = [...session.surface.nodes]
|
||||
if (!nodes.length) return ''
|
||||
const parts = []
|
||||
for (const seq of nodes) {
|
||||
const ev = session.eventAt(seq)
|
||||
if (!ev) continue
|
||||
let msg
|
||||
try { msg = session.deriveEventMessage(ev) } catch { continue }
|
||||
if (!msg || msg.source?.kind === 'omp-advisor') continue
|
||||
parts.push(renderMessage(msg))
|
||||
}
|
||||
st.lastReviewedSeq = nodes.at(-1)
|
||||
// Keep the head (task) and as much of the tail (latest steps) as fits.
|
||||
let out = parts.join('\n')
|
||||
if (out.length > cfg.maxDigestChars) {
|
||||
const head = parts.slice(0, 2).join('\n').slice(0, Math.floor(cfg.maxDigestChars * 0.25))
|
||||
const tail = out.slice(-Math.floor(cfg.maxDigestChars * 0.75))
|
||||
out = `${head}\n…[earlier steps omitted]…\n${tail}`
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function renderMessage(msg) {
|
||||
const role = msg.role === 'assistant' ? 'AGENT' : msg.role === 'tool' ? 'TOOL RESULT' : msg.role.toUpperCase()
|
||||
const bits = []
|
||||
for (const b of msg.content ?? []) {
|
||||
if (b.type === 'text') bits.push(clip(b.text, msg.role === 'tool' ? cfg.maxToolResultChars : 4000))
|
||||
else if (b.type === 'tool-call') bits.push(`→ ${b.name}(${clip(typeof b.arguments === 'string' ? b.arguments : JSON.stringify(b.arguments ?? b.input ?? {}), 1200)})`)
|
||||
else if (b.type === 'tool-result') bits.push(clip(contentText(b.content), cfg.maxToolResultChars))
|
||||
}
|
||||
return `### ${role}${msg.toolName ? ` (${msg.toolName})` : ''}\n${bits.join('\n')}`
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
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) })
|
||||
ctx.effect(() => () => { for (const d of installed.values()) d(); installed.clear() }, 'omp-advisor: agents')
|
||||
}
|
||||
|
||||
export function parseVerdict(text) {
|
||||
const m = /\{[\s\S]*\}/.exec(String(text ?? ''))
|
||||
if (!m) return null
|
||||
try {
|
||||
const v = JSON.parse(m[0])
|
||||
const verdict = String(v.verdict ?? '').toLowerCase()
|
||||
if (!['ok', 'note', 'concern', 'blocker'].includes(verdict)) return null
|
||||
return { verdict, message: String(v.message ?? '').trim().slice(0, 800) }
|
||||
} catch { return null }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "meta": { "title": "OMP Advisor", "description": "A second model reviews every agent step and injects notes, concerns or hard blockers into the conversation." } }
|
||||
@@ -0,0 +1 @@
|
||||
{ "meta": { "title": "OMP 顾问", "description": "第二个模型审查智能体的每一步,并向对话中注入提示、疑虑或阻断意见。" } }
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@local/omp-advisor",
|
||||
"version": "1.0.0",
|
||||
"description": "oh-my-pi style advisor for DSH: a second model reviews every agent step and injects notes, concerns or blockers.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": { ".": "./index.js", "./package.json": "./package.json", "./locale/*.json": "./locale/*.json" },
|
||||
"files": ["index.js", "cordis.patch.yml", "locale/*.json"],
|
||||
"scripts": { "test": "node --test test/*.test.mjs" },
|
||||
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import * as plugin from '../index.js'
|
||||
|
||||
function harness(replies, config = {}) {
|
||||
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 prompts = []
|
||||
const llm = {
|
||||
async *stream(opts) {
|
||||
prompts.push(opts)
|
||||
const r = replies.shift() ?? '{"verdict":"ok","message":""}'
|
||||
yield { type: 'text-delta', text: r }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
},
|
||||
}
|
||||
const events = []
|
||||
const session = {
|
||||
header: { cwd: '/w' },
|
||||
requestHeader: () => ({ config: { provider: 'omni', model: 'main-model' } }),
|
||||
surface: { get nodes() { return events.map((_, i) => i) } },
|
||||
eventAt: (i) => events[i],
|
||||
deriveEventMessage: (e) => e.msg,
|
||||
}
|
||||
const steered = []
|
||||
const agent = { session, ctx: { on }, steer: (m) => steered.push(m) }
|
||||
const ctx = { agents: { list: () => [] }, llm, on, effect() {} }
|
||||
plugin.apply(ctx, { minLevel: 'note', blocking: true, ...config })
|
||||
for (const f of listeners.get('agent/created') ?? []) f({ agent })
|
||||
const preStep = async (step) => {
|
||||
let out
|
||||
for (const f of listeners.get('agent/pre-step') ?? []) out = await f({ agent, messages: [], step, turn: 1 }, async () => ({ kind: 'enter', messages: [] }))
|
||||
return out
|
||||
}
|
||||
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 } })
|
||||
return { preStep, stopTurn, add, steered, prompts }
|
||||
}
|
||||
|
||||
test('parseVerdict', () => {
|
||||
assert.deepEqual(plugin.parseVerdict('x {"verdict":"Concern","message":"m"} y'), { verdict: 'concern', message: 'm' })
|
||||
assert.equal(plugin.parseVerdict('nope'), null)
|
||||
assert.equal(plugin.parseVerdict('{"verdict":"maybe"}'), null)
|
||||
})
|
||||
|
||||
test('a concern is injected into the next step as a system reminder', async () => {
|
||||
const h = harness(['{"verdict":"concern","message":"You deleted tests/ without asking."}'])
|
||||
h.add('user', [{ type: 'text', text: 'clean up the repo' }])
|
||||
h.add('assistant', [{ type: 'tool-call', name: 'bash', arguments: { command: 'rm -rf tests/' } }])
|
||||
h.add('tool', [{ type: 'text', text: '' }])
|
||||
const d = await h.preStep(2)
|
||||
assert.equal(d.kind, 'enter')
|
||||
const text = d.messages.at(-1).content[0].text
|
||||
assert.match(text, /Advisor concern\] You deleted tests\/ without asking/)
|
||||
assert.equal(h.prompts[0].model, 'main-model')
|
||||
assert.match(h.prompts[0].messages[0].content[0].text, /rm -rf tests/)
|
||||
})
|
||||
|
||||
test('ok verdicts inject nothing; first step is never reviewed', async () => {
|
||||
const h = harness(['{"verdict":"ok","message":""}'])
|
||||
h.add('user', [{ type: 'text', text: 'hi' }])
|
||||
assert.equal((await h.preStep(1)).messages.length, 0)
|
||||
assert.equal(h.prompts.length, 0)
|
||||
assert.equal((await h.preStep(2)).messages.length, 0)
|
||||
})
|
||||
|
||||
test('blocker at turn end steers the agent', async () => {
|
||||
const h = harness(['{"verdict":"blocker","message":"Do not push: tests are failing."}'], { blocking: false })
|
||||
h.add('user', [{ type: 'text', text: 'ship it' }])
|
||||
h.add('assistant', [{ type: 'tool-call', name: 'forge_push', arguments: {} }])
|
||||
await h.preStep(2) // review starts in background
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
await h.stopTurn()
|
||||
assert.equal(h.steered.length, 1)
|
||||
assert.match(h.steered[0].content[0].text, /ADVISOR BLOCKER.*Do not push/s)
|
||||
})
|
||||
|
||||
test('minLevel filters notes and duplicates are suppressed', async () => {
|
||||
const h = harness(['{"verdict":"note","message":"tiny"}', '{"verdict":"concern","message":"same"}', '{"verdict":"concern","message":"same"}'], { minLevel: 'concern' })
|
||||
h.add('user', [{ type: 'text', text: 'x' }])
|
||||
assert.equal((await h.preStep(2)).messages.length, 0) // note < minLevel: dropped
|
||||
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
|
||||
})
|
||||
Reference in new issue
Block a user