openvisio-agent 0.12.0 → 0.13.0
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 +91 -5
- 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.0' } } }, 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 }
|
|
@@ -639,12 +718,13 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
639
718
|
// fanned out org-wide. Catch both, keep only agent-assigned tasks, and let the
|
|
640
719
|
// cycle confirm ownership via get_marching_orders before acting.
|
|
641
720
|
if (k === 'task:created' || k === 'task:updated') {
|
|
642
|
-
const
|
|
643
|
-
const
|
|
721
|
+
const envelope = raw.data && typeof raw.data === 'object' ? raw.data : raw
|
|
722
|
+
const t = envelope.task && typeof envelope.task === 'object' ? envelope.task : envelope
|
|
723
|
+
const ag = (t.agent && typeof t.agent === 'object') ? t.agent : (t.assigned_agent && typeof t.assigned_agent === 'object') ? t.assigned_agent : null
|
|
724
|
+
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
725
|
if (agentId == null) return // not assigned to an agent — ignore
|
|
645
726
|
// If the payload carries the agent's identifier, filter precisely to US and skip
|
|
646
727
|
// other agents' tasks entirely; otherwise let get_marching_orders confirm.
|
|
647
|
-
const ag = (t.agent && typeof t.agent === 'object') ? t.agent : null
|
|
648
728
|
const agIdent = ag && (ag.identifier || ag.slug) ? String(ag.identifier || ag.slug) : null
|
|
649
729
|
if (agIdent != null && agIdent !== identifier) return
|
|
650
730
|
const key = `${t.id}:${agentId}`
|
|
@@ -719,10 +799,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
719
799
|
// so anything assigned while the agent was offline — tasks especially — is still
|
|
720
800
|
// picked up even though we don't hot-poll.
|
|
721
801
|
log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
|
|
722
|
-
handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, log })
|
|
802
|
+
handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect: () => { setTimeout(() => void reconcileBacklog(), 250) }, log })
|
|
723
803
|
|
|
724
804
|
const DAY_MS = 24 * 60 * 60 * 1000
|
|
725
|
-
let introTimer = null, sweepStartTimer = null, sweepTimer = null
|
|
805
|
+
let introTimer = null, sweepStartTimer = null, sweepTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
|
|
726
806
|
if (mcpConfig || mcpUrl) {
|
|
727
807
|
// Workspace ethics: a one-time hello the FIRST time this agent ever connects.
|
|
728
808
|
const introMarker = join(OV_DIR, 'intro-' + slugify(identifier) + '.done')
|
|
@@ -740,6 +820,10 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
740
820
|
log('startup catch-up sweep skipped (ran within the last 6h)')
|
|
741
821
|
}
|
|
742
822
|
sweepTimer = setInterval(() => { log('daily catch-up sweep'); void drain('sweep', SWEEP) }, DAY_MS)
|
|
823
|
+
// Cheap reliability net: no model runs unless the MCP result actually contains
|
|
824
|
+
// assigned work. This also catches assignments created while the socket was down.
|
|
825
|
+
taskProbeStartTimer = setTimeout(() => void reconcileBacklog(), 3_000)
|
|
826
|
+
taskProbeTimer = setInterval(() => void reconcileBacklog(), 5 * 60 * 1000)
|
|
743
827
|
} else {
|
|
744
828
|
log('no MCP config — skipping intro + daily sweep (agent has no tools to post/act)')
|
|
745
829
|
}
|
|
@@ -751,6 +835,8 @@ function loopBackendWs({ wsUrl, apiKey, identifier, slug, claude, agent, mcpConf
|
|
|
751
835
|
if (introTimer) clearTimeout(introTimer)
|
|
752
836
|
if (sweepStartTimer) clearTimeout(sweepStartTimer)
|
|
753
837
|
if (sweepTimer) clearInterval(sweepTimer)
|
|
838
|
+
if (taskProbeStartTimer) clearTimeout(taskProbeStartTimer)
|
|
839
|
+
if (taskProbeTimer) clearInterval(taskProbeTimer)
|
|
754
840
|
try { handle && handle.close() } catch { /* noop */ }
|
|
755
841
|
process.exit(0)
|
|
756
842
|
}
|
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 */ }
|