openvisio-agent 0.19.8 → 0.19.9
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 +1 -1
- package/package.json +1 -1
- package/scripts/certify.mjs +1 -1
- package/src/mastra-harness.mjs +14 -6
- package/src/model-selection.mjs +12 -12
- package/src/watch.mjs +14 -7
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ Runs the **autonomy loop** — the agent replies to @mentions and picks up ticke
|
|
|
58
58
|
|
|
59
59
|
Codex and OpenCode cycles run through Mastra's ACP harness. Codex uses the packaged `codex-acp` adapter and reuses the machine's existing ChatGPT/Codex login; OpenCode uses its native `opencode acp` server. Each accepted ticket gets its own ACP session and worktree. Mastra Memory stores ticket/thread context in local libSQL under `~/.openvisio/`, while a compact JSON ledger retains only exact replay, cancellation, and delivery keys.
|
|
60
60
|
|
|
61
|
-
Model
|
|
61
|
+
Model selections are stable. Exact IDs (including reasoning effort) are preserved. An unsuffixed ACP model may resolve once to the same model with `medium` effort; the watcher saves that exact selection. Unavailable selections produce an explicit error rather than switching generations or families. Claude versioned IDs are preserved, with automatic family fallbacks disabled. Codex uses the configured chat model for replies.
|
|
62
62
|
|
|
63
63
|
Backend/BYO watchers reconcile immediately whenever the process starts or the WebSocket connects. A direct MCP session discovers and caches the backend's actual `tools/list` response, then uses available tools such as `list_agents`, `list_projects`, `list_tasks`, `list_task_types`, and `list_activity` to recover assigned tasks and recent mention activity missed while offline. Optional actions such as ticket comments are used only when advertised; their absence cannot strand a completed ticket in a retry loop. The same zero-model check runs every five minutes as a safety net; a model starts only when pending work exists.
|
|
64
64
|
|
package/package.json
CHANGED
package/scripts/certify.mjs
CHANGED
|
@@ -77,7 +77,7 @@ const assertions = [
|
|
|
77
77
|
['MCP requests have a deadline including response bodies', mcpHttp.includes('controller.abort()') && mcpHttp.includes('const body = await res.text()')],
|
|
78
78
|
['Mastra memory uses real ticket and thread identities', watcher.includes('createMastraMemory') && watcher.includes('await memory.context(memoryRefs)') && memory.includes('new Memory({ storage') && memory.includes('new LibSQLStore') && memory.includes('ticket:${refs.projectId}:${refs.ticketId}') && memory.includes('channel:${refs.channelId}:thread:')],
|
|
79
79
|
['Codex and OpenCode use warm scoped Mastra ACP sessions', watcher.includes('createMastraAcpRunner') && watcher.includes('const replyRunners = new Map()') && watcher.includes('replyRunnerFor(item.delivery)') && mastraHarness.includes('new AcpAgentClass') && mastraHarness.includes('persistSession: true') && mastraHarness.includes("runtime: 'mastra-acp'")],
|
|
80
|
-
['runtime models are
|
|
80
|
+
['runtime models are selected explicitly before work starts', mastraHarness.includes('getAvailableModels()') && mastraHarness.includes('resolveAvailableModel(requestedModel') && mastraHarness.includes('await acp.setModel(resolution.selected)') && modelSelection.includes('automatic model substitution is disabled') && watcher.includes('if (changed) persistModel()')],
|
|
81
81
|
['one watcher owns isolated concurrent work and thread-scoped serial reply runtimes', watcher.includes('MAX_CONCURRENT_WORKERS = 3') && watcher.includes('createWorkRunner(item.control, item.workdir)') && watcher.includes('const replyRunners = new Map()') && watcher.includes('while (replyRunners.size > 8)')],
|
|
82
82
|
['workers serialize shared worktrees while independent worktrees run concurrently', watcher.includes('groupKey: (item) => item.workdir || workdir') && cycleQueue.includes('active.size < concurrency') && cycleQueue.includes('activeGroups.has(entry.group)')],
|
|
83
83
|
['work and reply cancellation targets are isolated', watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent')],
|
package/src/mastra-harness.mjs
CHANGED
|
@@ -98,7 +98,7 @@ export function createMastraAcpRunner({
|
|
|
98
98
|
const headers = Object.entries(mcpHeaders).filter(([, value]) => value != null && value !== '').map(([name, value]) => ({ name, value: String(value) }))
|
|
99
99
|
const disabledMcpTools = Array.isArray(cycleOptions.disabledMcpTools) ? cycleOptions.disabledMcpTools.filter(Boolean) : []
|
|
100
100
|
const mcpServers = !mcpUrl ? [] : agent === 'codex' ? [{
|
|
101
|
-
name: 'openvisio-team', command: process.execPath, args: [proxyPath], env: [
|
|
101
|
+
name: 'openvisio-team-watcher', command: process.execPath, args: [proxyPath], env: [
|
|
102
102
|
{ name: 'OPENVISIO_CODEX_MCP_URL', value: mcpUrl },
|
|
103
103
|
{ name: 'OPENVISIO_CODEX_API_KEY', value: String(mcpHeaders['x-agent-api-key'] || '') },
|
|
104
104
|
{ name: 'OPENVISIO_CODEX_IDENTIFIER', value: String(mcpHeaders['x-agent-identifier'] || '') },
|
|
@@ -119,6 +119,10 @@ export function createMastraAcpRunner({
|
|
|
119
119
|
env: agent === 'codex' ? {
|
|
120
120
|
INITIAL_AGENT_MODE: canCode ? 'agent' : 'read-only',
|
|
121
121
|
NO_BROWSER: '1',
|
|
122
|
+
// codex-acp otherwise keeps a pre-existing openvisio-team server and
|
|
123
|
+
// silently discards this session's authenticated stdio bridge.
|
|
124
|
+
DISABLE_MCP_CONFIG_FILTERING: 'true',
|
|
125
|
+
CODEX_CONFIG: JSON.stringify({ 'mcp_servers.openvisio-team.enabled': false }),
|
|
122
126
|
} : {},
|
|
123
127
|
session: {
|
|
124
128
|
cwd: cycleCwd,
|
|
@@ -161,9 +165,9 @@ export function createMastraAcpRunner({
|
|
|
161
165
|
if (requestedModel !== negotiatedRequested) {
|
|
162
166
|
const availableModels = await acp.getAvailableModels()
|
|
163
167
|
const resolution = resolveAvailableModel(requestedModel, availableModels)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
168
|
+
if (resolution.selected) {
|
|
169
|
+
await acp.setModel(resolution.selected)
|
|
170
|
+
selectedModel = resolution.selected
|
|
167
171
|
if (selectedModel !== requestedModel) log(`model ${requestedModel} resolved to available ${selectedModel} [${resolution.reason}]`)
|
|
168
172
|
} else {
|
|
169
173
|
log(`model ${requestedModel} could not be negotiated because ${agent} did not advertise selectable models; using its session default`)
|
|
@@ -209,7 +213,9 @@ export function createMastraAcpRunner({
|
|
|
209
213
|
const after = repositorySnapshot(canCode ? cycleCwd : '')
|
|
210
214
|
if (before !== after) { didCode = true; didRepoMutation = true }
|
|
211
215
|
didResultMessage = didChannelMessage && didRepoMutation
|
|
212
|
-
|
|
216
|
+
const redact = (value) => Object.values(mcpHeaders).filter(Boolean).reduce((safe, secret) => safe.split(String(secret)).join('[redacted]'), String(value))
|
|
217
|
+
for (const [name, detail] of errors) log(`MCP ${name} failed: ${redact(detail)}`)
|
|
218
|
+
log(`${agent} cycle done via Mastra ACP (${errors.size ? 'tool-errors' : 'ok'})`)
|
|
213
219
|
return {
|
|
214
220
|
type: 'result', subtype: 'ok', runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
|
|
215
221
|
mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
|
|
@@ -219,7 +225,9 @@ export function createMastraAcpRunner({
|
|
|
219
225
|
} catch (error) {
|
|
220
226
|
const canceled = controller.signal.aborted
|
|
221
227
|
const subtype = timedOut ? 'timeout' : canceled ? 'canceled' : 'error'
|
|
222
|
-
|
|
228
|
+
const errorDetail = [error?.message, error?.data ? json(error.data) : ''].filter(Boolean).join(': ')
|
|
229
|
+
const safeError = Object.values(mcpHeaders).filter(Boolean).reduce((safe, secret) => safe.split(String(secret)).join('[redacted]'), errorDetail)
|
|
230
|
+
log(`${agent} cycle done via Mastra ACP (${subtype}${!canceled && safeError ? ': ' + clean(safeError, 1200) : ''})`)
|
|
223
231
|
invalidate()
|
|
224
232
|
return {
|
|
225
233
|
type: 'result', subtype, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
|
package/src/model-selection.mjs
CHANGED
|
@@ -40,10 +40,18 @@ function similarity(requested, candidate, preferredEffort) {
|
|
|
40
40
|
export function resolveAvailableModel(requestedValue, availableModels = [], { preferredEffort = DEFAULT_EFFORT } = {}) {
|
|
41
41
|
const requested = parts(requestedValue)
|
|
42
42
|
const choices = availableModels.map((entry) => typeof entry === 'string' ? entry : entry?.modelId).map(text).filter(Boolean)
|
|
43
|
-
if (!choices.length) return { requested: requested.raw, selected:
|
|
43
|
+
if (!choices.length && requested.raw) return { requested: requested.raw, selected: requested.raw, reason: 'explicit-unadvertised', available: [] }
|
|
44
|
+
if (!choices.length) return { requested: '', selected: '', reason: 'runtime-default', available: [] }
|
|
44
45
|
if (!requested.raw) return { requested: '', selected: '', reason: 'runtime-default', available: choices }
|
|
45
46
|
|
|
46
|
-
const
|
|
47
|
+
const compatible = choices.filter((id) => {
|
|
48
|
+
const candidate = parts(id)
|
|
49
|
+
return candidate.id === requested.id || (!requested.effort &&
|
|
50
|
+
candidate.leaf === requested.leaf && (!requested.provider || candidate.provider === requested.provider) &&
|
|
51
|
+
(!candidate.effort || candidate.effort === preferredEffort))
|
|
52
|
+
})
|
|
53
|
+
if (!compatible.length) throw new Error(`Configured model "${requested.raw}" is unavailable; automatic model substitution is disabled.`)
|
|
54
|
+
const ranked = compatible.map((id, index) => {
|
|
47
55
|
const candidate = parts(id)
|
|
48
56
|
return { id, index, score: similarity(requested, candidate, preferredEffort) }
|
|
49
57
|
}).sort((a, b) => b.score - a.score || a.index - b.index)
|
|
@@ -59,17 +67,9 @@ export function resolveAvailableModel(requestedValue, availableModels = [], { pr
|
|
|
59
67
|
const CLAUDE_FAMILIES = ['sonnet', 'opus', 'haiku', 'fable']
|
|
60
68
|
|
|
61
69
|
export function resolveClaudeModel(requestedValue) {
|
|
62
|
-
|
|
63
|
-
if (!requested) return ''
|
|
64
|
-
const family = CLAUDE_FAMILIES.find((name) => requested === name || requested.startsWith('claude-') && requested.includes(name))
|
|
65
|
-
return family || text(requestedValue)
|
|
70
|
+
return text(requestedValue)
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
export function claudeFallbackModels(selectedValue) {
|
|
69
|
-
|
|
70
|
-
const preferred = selected === 'opus' ? ['sonnet', 'haiku']
|
|
71
|
-
: selected === 'haiku' ? ['sonnet', 'opus']
|
|
72
|
-
: selected === 'fable' ? ['sonnet', 'opus'] : ['opus', 'haiku']
|
|
73
|
-
return preferred.filter((model) => model !== selected)
|
|
74
|
+
return []
|
|
74
75
|
}
|
|
75
|
-
|
package/src/watch.mjs
CHANGED
|
@@ -261,10 +261,9 @@ export async function runWatch({ flags }) {
|
|
|
261
261
|
const workdir = chatOnly ? '' : (explicitWorkdir || (saved && (saved.workspace || saved.workdir)) || DEFAULT_WORKSPACE)
|
|
262
262
|
if (workdir) { try { mkdirSync(workdir, { recursive: true }) } catch { /* best-effort; spawn will surface a real problem */ } }
|
|
263
263
|
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
const defaultChatModel = agent === 'claude' ? 'haiku' : agent === 'codex' ? 'gpt-5.6-sol' : ''
|
|
264
|
+
// Use a specific Codex ID; configured chat and code tiers remain independent.
|
|
265
|
+
const defaultModel = agent === 'claude' ? 'sonnet' : agent === 'codex' ? 'gpt-5.6-sol[medium]' : ''
|
|
266
|
+
const defaultChatModel = agent === 'claude' ? 'haiku' : agent === 'codex' ? 'gpt-5.6-sol[medium]' : ''
|
|
268
267
|
const model = String(flags.model || (saved && saved.model) || defaultModel)
|
|
269
268
|
const chatModel = String(flags['chat-model'] || (saved && saved.chatModel) || defaultChatModel)
|
|
270
269
|
|
|
@@ -1562,7 +1561,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1562
1561
|
const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind, delivery)
|
|
1563
1562
|
// Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
|
|
1564
1563
|
// work (full/sweep) uses the main model.
|
|
1565
|
-
const useModel =
|
|
1564
|
+
const useModel = kind === 'full' ? codeModel : liteModel
|
|
1566
1565
|
log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : '') + (useModel ? ' [' + useModel + ']' : ''))
|
|
1567
1566
|
// Presence notifications are intentionally disabled; durable messages and
|
|
1568
1567
|
// ticket transitions are the only user-visible progress signals.
|
|
@@ -1584,6 +1583,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1584
1583
|
}
|
|
1585
1584
|
if (cycleControl.cancelled) return
|
|
1586
1585
|
const result = await runner.runCycle(prompt, useModel, runnerOptions)
|
|
1586
|
+
if (result?.model && result.model !== useModel) {
|
|
1587
|
+
// Persist only a successfully selected model and only if the user has
|
|
1588
|
+
// not changed that tier while this cycle was running.
|
|
1589
|
+
let changed = false
|
|
1590
|
+
if (codeModel === useModel) { codeModel = result.model; changed = true }
|
|
1591
|
+
if (liteModel === useModel) { liteModel = result.model; changed = true }
|
|
1592
|
+
if (changed) persistModel()
|
|
1593
|
+
}
|
|
1587
1594
|
let completionResult = result
|
|
1588
1595
|
if (cycleControl.cancelled || result?.subtype === 'canceled') {
|
|
1589
1596
|
log(laneName + ' cycle cancelled; no blocker or reply will be published')
|
|
@@ -1686,9 +1693,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1686
1693
|
const x = String(s || '').toLowerCase().replace(/[.,!?]+$/, '')
|
|
1687
1694
|
if (x === 'opus' || x === 'sonnet' || x === 'haiku') return x
|
|
1688
1695
|
if (/^claude-[a-z0-9.\-\[\]]+$/i.test(x)) return x
|
|
1689
|
-
if (/^(?:gpt|codex|o[1-9])[a-z0-9.\-:]
|
|
1696
|
+
if (/^(?:gpt|codex|o[1-9])[a-z0-9.\-:]*(?:\[(?:low|medium|high|xhigh|max|ultra)\])?$/i.test(x)) return x
|
|
1690
1697
|
// opencode models are provider/model (e.g. anthropic/claude-sonnet-4, openai/gpt-4o).
|
|
1691
|
-
return /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9.\-:]
|
|
1698
|
+
return /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9.\-:]*(?:\[(?:low|medium|high|xhigh|max|ultra)\])?$/i.test(x) ? x : null
|
|
1692
1699
|
}
|
|
1693
1700
|
// Optional target tier: "chat"/"lite" → chat cycles only, "code"/"full" → code
|
|
1694
1701
|
// cycles only, absent → both.
|