openvisio-agent 0.12.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/watch.mjs +95 -8
- package/src/ws.mjs +2 -1
package/README.md
CHANGED
|
@@ -56,6 +56,8 @@ Then `watch --name ada` auto-detects the backend agent and runs a WebSocket loop
|
|
|
56
56
|
|
|
57
57
|
Runs the **autonomy loop** — the agent replies to @mentions and picks up tickets on its own. It cheaply polls an inbox endpoint (no model spend when idle) and pokes a single warm Claude Code session only when something new arrives.
|
|
58
58
|
|
|
59
|
+
Backend/BYO watchers reconcile immediately whenever the process starts or the WebSocket connects. A direct MCP session checks both `poll_inbox` and `get_marching_orders`, covering unanswered mentions, follow-ups, and assigned tasks missed while offline. The same zero-model check runs every five minutes as a safety net; a model starts only when pending work exists.
|
|
60
|
+
|
|
59
61
|
Routine messages, triage, introductions, catch-up checks, and ticket movement use a lightweight model by default. Claude uses Haiku and Codex uses `gpt-5.6-luna`. Requests that clearly require repository work route to the coding model: Sonnet for Claude and `gpt-5.6-sol` for Codex. Override either lane with `--chat-model` and `--model`.
|
|
60
62
|
|
|
61
63
|
```bash
|
package/package.json
CHANGED
package/src/watch.mjs
CHANGED
|
@@ -550,6 +550,85 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
550
550
|
// hoping poll_inbox re-surfaces the same item. Accumulated across coalesced
|
|
551
551
|
// events and drained into the next cycle's prompt.
|
|
552
552
|
const pending = []
|
|
553
|
+
let backlogProbeBusy = false
|
|
554
|
+
let lastTaskSignature = ''
|
|
555
|
+
let lastTaskTriggeredAt = 0
|
|
556
|
+
let mcpSessionId = ''
|
|
557
|
+
let mcpRpcId = 0
|
|
558
|
+
|
|
559
|
+
const mcpPayload = async (res) => {
|
|
560
|
+
const body = await res.text()
|
|
561
|
+
const data = body.split(/\r?\n/).filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim()).pop()
|
|
562
|
+
return JSON.parse(data || body || '{}')
|
|
563
|
+
}
|
|
564
|
+
const mcpPost = async (message, withSession = true) => {
|
|
565
|
+
const res = await fetch(mcpUrl, {
|
|
566
|
+
method: 'POST',
|
|
567
|
+
headers: {
|
|
568
|
+
'content-type': 'application/json',
|
|
569
|
+
accept: 'application/json, text/event-stream',
|
|
570
|
+
'x-agent-api-key': apiKey,
|
|
571
|
+
'x-agent-identifier': identifier,
|
|
572
|
+
...(withSession && mcpSessionId ? { 'mcp-session-id': mcpSessionId } : {}),
|
|
573
|
+
},
|
|
574
|
+
body: JSON.stringify(message),
|
|
575
|
+
})
|
|
576
|
+
return res
|
|
577
|
+
}
|
|
578
|
+
const ensureMcpSession = async () => {
|
|
579
|
+
if (mcpSessionId) return
|
|
580
|
+
const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.13.1' } } }, false)
|
|
581
|
+
if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
|
|
582
|
+
await mcpPayload(res)
|
|
583
|
+
mcpSessionId = res.headers.get('mcp-session-id') || ''
|
|
584
|
+
if (!mcpSessionId) throw new Error('MCP initialize returned no session id')
|
|
585
|
+
const ready = await mcpPost({ jsonrpc: '2.0', method: 'notifications/initialized' })
|
|
586
|
+
if (!ready.ok) throw new Error('MCP initialized HTTP ' + ready.status)
|
|
587
|
+
}
|
|
588
|
+
const callMcpTool = async (name, args = {}, retried = false) => {
|
|
589
|
+
await ensureMcpSession()
|
|
590
|
+
const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'tools/call', params: { name, arguments: { ...args, agent_api_key: apiKey, agent_identifier: identifier } } })
|
|
591
|
+
if (!res.ok) {
|
|
592
|
+
if (!retried && (res.status === 400 || res.status === 404)) { mcpSessionId = ''; return callMcpTool(name, args, true) }
|
|
593
|
+
throw new Error(`MCP ${name} HTTP ${res.status}`)
|
|
594
|
+
}
|
|
595
|
+
const payload = await mcpPayload(res)
|
|
596
|
+
if (payload.error) throw new Error(`MCP ${name}: ${payload.error.message || 'tool error'}`)
|
|
597
|
+
return JSON.stringify(payload.result ?? payload)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Reconcile everything that may have arrived while disconnected. This spends no
|
|
601
|
+
// model tokens unless the tools actually report pending work.
|
|
602
|
+
const reconcileBacklog = async () => {
|
|
603
|
+
if (!mcpUrl || backlogProbeBusy || busy) return
|
|
604
|
+
backlogProbeBusy = true
|
|
605
|
+
try {
|
|
606
|
+
const orders = await callMcpTool('get_marching_orders')
|
|
607
|
+
const inbox = await callMcpTool('poll_inbox')
|
|
608
|
+
const normalized = String(orders).replace(/\\"/g, '"').replace(/\s+/g, ' ').trim()
|
|
609
|
+
const empty = !normalized || /\b(?:no|zero)\s+(?:open\s+|assigned\s+|pending\s+)?tasks?\b/i.test(normalized) || /"(?:tasks|assigned|marching_orders)"\s*:\s*\[\s*\]/i.test(normalized)
|
|
610
|
+
if (empty) lastTaskSignature = ''
|
|
611
|
+
else if (/\b(?:task|ticket|title|status|assigned)\b/i.test(normalized)) {
|
|
612
|
+
const signature = normalized.slice(0, 1200)
|
|
613
|
+
const retryDue = Date.now() - lastTaskTriggeredAt > 30 * 60 * 1000
|
|
614
|
+
if (signature !== lastTaskSignature || retryDue) {
|
|
615
|
+
lastTaskSignature = signature
|
|
616
|
+
lastTaskTriggeredAt = Date.now()
|
|
617
|
+
log('backlog reconciliation found pending tasks -> coding cycle')
|
|
618
|
+
void drain('full', 'Backlog reconciliation found one or more tasks in get_marching_orders. Call get_marching_orders now, select every open task assigned to YOU, and execute the oldest/highest-priority one immediately. Do not merely acknowledge it. Move it to the active column, complete the repository work, verify it, open the PR, move the ticket to done when appropriate, and report evidence. Ignore tasks assigned to anyone else.')
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
const inboxText = String(inbox).replace(/\\"/g, '"').replace(/\s+/g, ' ').trim()
|
|
622
|
+
const hasInbox = /"(?:mentions|messages|follow_?ups|items)"\s*:\s*\[\s*\{/i.test(inboxText) || /\b(?:pending|unanswered|new)\s+(?:mention|message|follow.?up)/i.test(inboxText)
|
|
623
|
+
if (hasInbox) {
|
|
624
|
+
log('backlog reconciliation found inbox activity -> reply cycle')
|
|
625
|
+
void drain('fast', 'Backlog reconciliation found pending inbox activity. Call poll_inbox now and handle every unanswered mention or follow-up directed to YOU. Do not respond to other agents\' messages and do not duplicate a reply already present.')
|
|
626
|
+
}
|
|
627
|
+
} catch (e) {
|
|
628
|
+
mcpSessionId = ''
|
|
629
|
+
log('backlog reconciliation failed: ' + (e && e.message ? e.message : e))
|
|
630
|
+
} finally { backlogProbeBusy = false }
|
|
631
|
+
}
|
|
553
632
|
|
|
554
633
|
// Higher rank wins when coalescing cycles requested while one is running.
|
|
555
634
|
const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
|
|
@@ -577,9 +656,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
577
656
|
// Codex can exit successfully after posting only "I'll do it". For coding
|
|
578
657
|
// cycles, treat that as incomplete and give it one focused recovery turn.
|
|
579
658
|
// Do not retry a real blocker, which would only create duplicate noise.
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
659
|
+
const legitimateNoWork = /\b(?:no (?:open |assigned |pending )?tasks?|not assigned to (?:me|you)|assigned to (?:another|someone else)|blocked|cannot|can't|missing|need access|permission|unclear)\b/i.test(result?.outputText || '')
|
|
660
|
+
if (agent === 'codex' && kind === 'full' && result?.subtype === 'ok' && !result.didCode && !legitimateNoWork) {
|
|
661
|
+
log('coding cycle exited without repository action; running one completion recovery')
|
|
662
|
+
await runCycle('You exited the assigned coding task without performing repository work. Do not post another acknowledgement or plan. Resume the same task now: inspect the workspace, make the required changes, verify them, commit and push an agent/* branch, open the PR, then post exactly one result update with evidence. If a real blocker appears, report that blocker once.', codeModel)
|
|
583
663
|
}
|
|
584
664
|
} finally {
|
|
585
665
|
if (heartbeat) clearInterval(heartbeat)
|
|
@@ -639,12 +719,13 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
639
719
|
// fanned out org-wide. Catch both, keep only agent-assigned tasks, and let the
|
|
640
720
|
// cycle confirm ownership via get_marching_orders before acting.
|
|
641
721
|
if (k === 'task:created' || k === 'task:updated') {
|
|
642
|
-
const
|
|
643
|
-
const
|
|
722
|
+
const envelope = raw.data && typeof raw.data === 'object' ? raw.data : raw
|
|
723
|
+
const t = envelope.task && typeof envelope.task === 'object' ? envelope.task : envelope
|
|
724
|
+
const ag = (t.agent && typeof t.agent === 'object') ? t.agent : (t.assigned_agent && typeof t.assigned_agent === 'object') ? t.assigned_agent : null
|
|
725
|
+
const agentId = t.agent_id ?? t.agentId ?? t.assigned_agent_id ?? t.assignedAgentId ?? t.assignee_agent_id ?? t.assigneeAgentId ?? ag?.id ?? ag?.agent_id ?? null
|
|
644
726
|
if (agentId == null) return // not assigned to an agent — ignore
|
|
645
727
|
// If the payload carries the agent's identifier, filter precisely to US and skip
|
|
646
728
|
// other agents' tasks entirely; otherwise let get_marching_orders confirm.
|
|
647
|
-
const ag = (t.agent && typeof t.agent === 'object') ? t.agent : null
|
|
648
729
|
const agIdent = ag && (ag.identifier || ag.slug) ? String(ag.identifier || ag.slug) : null
|
|
649
730
|
if (agIdent != null && agIdent !== identifier) return
|
|
650
731
|
const key = `${t.id}:${agentId}`
|
|
@@ -719,10 +800,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
719
800
|
// so anything assigned while the agent was offline — tasks especially — is still
|
|
720
801
|
// picked up even though we don't hot-poll.
|
|
721
802
|
log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
|
|
722
|
-
handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, log })
|
|
803
|
+
handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect: () => { setTimeout(() => void reconcileBacklog(), 250) }, log })
|
|
723
804
|
|
|
724
805
|
const DAY_MS = 24 * 60 * 60 * 1000
|
|
725
|
-
let introTimer = null, sweepStartTimer = null, sweepTimer = null
|
|
806
|
+
let introTimer = null, sweepStartTimer = null, sweepTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
|
|
726
807
|
if (mcpConfig || mcpUrl) {
|
|
727
808
|
// Workspace ethics: a one-time hello the FIRST time this agent ever connects.
|
|
728
809
|
const introMarker = join(OV_DIR, 'intro-' + slugify(identifier) + '.done')
|
|
@@ -740,6 +821,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
740
821
|
log('startup catch-up sweep skipped (ran within the last 6h)')
|
|
741
822
|
}
|
|
742
823
|
sweepTimer = setInterval(() => { log('daily catch-up sweep'); void drain('sweep', SWEEP) }, DAY_MS)
|
|
824
|
+
// Cheap reliability net: no model runs unless the MCP result actually contains
|
|
825
|
+
// assigned work. This also catches assignments created while the socket was down.
|
|
826
|
+
taskProbeStartTimer = setTimeout(() => void reconcileBacklog(), 3_000)
|
|
827
|
+
taskProbeTimer = setInterval(() => void reconcileBacklog(), 5 * 60 * 1000)
|
|
743
828
|
} else {
|
|
744
829
|
log('no MCP config — skipping intro + daily sweep (agent has no tools to post/act)')
|
|
745
830
|
}
|
|
@@ -751,6 +836,8 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
751
836
|
if (introTimer) clearTimeout(introTimer)
|
|
752
837
|
if (sweepStartTimer) clearTimeout(sweepStartTimer)
|
|
753
838
|
if (sweepTimer) clearInterval(sweepTimer)
|
|
839
|
+
if (taskProbeStartTimer) clearTimeout(taskProbeStartTimer)
|
|
840
|
+
if (taskProbeTimer) clearInterval(taskProbeTimer)
|
|
754
841
|
try { handle && handle.close() } catch { /* noop */ }
|
|
755
842
|
process.exit(0)
|
|
756
843
|
}
|
package/src/ws.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export function assertWebSocket(fail) {
|
|
|
36
36
|
* @param {(kind: string, payload: any) => void} o.onEvent targeted dispatch.
|
|
37
37
|
* @param {(msg: string) => void} o.log
|
|
38
38
|
*/
|
|
39
|
-
export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, log }) {
|
|
39
|
+
export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect, log }) {
|
|
40
40
|
const base = stripSlash(wsUrl)
|
|
41
41
|
const url = `${base}?api_key=${encodeURIComponent(apiKey)}&identifier=${encodeURIComponent(identifier)}`
|
|
42
42
|
|
|
@@ -66,6 +66,7 @@ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, log }) {
|
|
|
66
66
|
if (sock !== ws) return
|
|
67
67
|
backoff = BACKOFF_MIN_MS // healthy connection → reset the backoff ramp
|
|
68
68
|
log(`connected as @${identifier}`)
|
|
69
|
+
try { onConnect && onConnect() } catch { /* reconciliation is best-effort */ }
|
|
69
70
|
clearKeepalive()
|
|
70
71
|
keepalive = setInterval(() => {
|
|
71
72
|
try { sock.send(JSON.stringify({ type: 'keepalive' })) } catch { /* closing */ }
|