openvisio-agent 0.19.0 → 0.19.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/scripts/certify.mjs +2 -0
- package/src/mastra-harness.mjs +19 -5
- package/src/model-selection.mjs +75 -0
- package/src/watch.mjs +9 -5
package/README.md
CHANGED
|
@@ -58,6 +58,8 @@ 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 names are negotiated at session start. Codex and OpenCode match the configured name against the models advertised by ACP, preferring the same family and reasoning effort (`medium` for an unsuffixed family); provider-qualified and nearby generation names are resolved automatically. Claude versioned names are normalized to its stable `sonnet`, `opus`, `haiku`, or `fable` aliases and use the CLI's native ordered fallback chain.
|
|
62
|
+
|
|
61
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.
|
|
62
64
|
|
|
63
65
|
The backend MCP may be stateful or stateless. A successful initialize response without `Mcp-Session-Id` is accepted as stateless, so OpenCode agents do not stop with “MCP initialize returned no session id.” Each OpenCode lane keeps its MCP identity in a private per-agent config directory while the repository is supplied separately with `--dir`; stale workspace configuration therefore cannot swap one agent's credentials for another's. The generated remote configuration sends the agent headers directly, disables OAuth probing, and backend cycles never request relay-only inbox calls or MCP resource-discovery tools in place of team actions.
|
package/package.json
CHANGED
package/scripts/certify.mjs
CHANGED
|
@@ -32,6 +32,7 @@ const cli = readFileSync(join(root, 'bin', 'cli.mjs'), 'utf8')
|
|
|
32
32
|
const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
|
|
33
33
|
const cycleQueue = readFileSync(join(root, 'src', 'cycle-queue.mjs'), 'utf8')
|
|
34
34
|
const mastraHarness = readFileSync(join(root, 'src', 'mastra-harness.mjs'), 'utf8')
|
|
35
|
+
const modelSelection = readFileSync(join(root, 'src', 'model-selection.mjs'), 'utf8')
|
|
35
36
|
const activityHook = readFileSync(join(repo, 'frontend', 'hooks', 'useAgentActivity.ts'), 'utf8')
|
|
36
37
|
const taskHook = readFileSync(join(repo, 'frontend', 'hooks', 'useBackendTasks.ts'), 'utf8')
|
|
37
38
|
const liveTasks = readFileSync(join(repo, 'frontend', 'lib', 'collab', 'liveTasks.ts'), 'utf8')
|
|
@@ -74,6 +75,7 @@ const assertions = [
|
|
|
74
75
|
['MCP requests have a deadline including response bodies', mcpHttp.includes('controller.abort()') && mcpHttp.includes('const body = await res.text()')],
|
|
75
76
|
['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:')],
|
|
76
77
|
['Codex and OpenCode use isolated Mastra ACP sessions', watcher.includes('createMastraAcpRunner') && mastraHarness.includes('new AcpAgentClass') && mastraHarness.includes('persistSession: false') && mastraHarness.includes("runtime: 'mastra-acp'")],
|
|
78
|
+
['runtime models are resolved before work starts', mastraHarness.includes('getAvailableModels()') && mastraHarness.includes('resolveAvailableModel(requestedModel') && mastraHarness.includes('await acp.setModel(selectedModel)') && modelSelection.includes("const DEFAULT_EFFORT = 'medium'") && watcher.includes('resolveClaudeModel') && watcher.includes("'--fallback-model'" )],
|
|
77
79
|
['one watcher owns isolated concurrent work and serial reply runtimes', watcher.includes('MAX_CONCURRENT_WORKERS = 3') && watcher.includes('createWorkRunner(item.control, item.workdir)') && watcher.includes('const replyRunner = createCycleRunner')],
|
|
78
80
|
['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)')],
|
|
79
81
|
['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
|
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'
|
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
3
|
import { AcpAgent } from '@mastra/acp'
|
|
4
4
|
import { onPath } from './lib.mjs'
|
|
5
|
+
import { resolveAvailableModel } from './model-selection.mjs'
|
|
5
6
|
|
|
6
7
|
const MCP_TOOL_NAMES = [
|
|
7
8
|
'list_agents', 'list_projects', 'list_tasks', 'list_task_types', 'get_ticket',
|
|
@@ -97,7 +98,6 @@ export function createMastraAcpRunner({
|
|
|
97
98
|
mcpServers,
|
|
98
99
|
},
|
|
99
100
|
persistSession: false,
|
|
100
|
-
...(cycleModel || model ? { model: cycleModel || model } : {}),
|
|
101
101
|
onPermissionRequest: async ({ options }) => {
|
|
102
102
|
const kind = canCode ? 'allow_once' : 'reject_once'
|
|
103
103
|
const selected = options.find((option) => option.kind === kind) || options.find((option) => option.kind.startsWith(canCode ? 'allow' : 'reject'))
|
|
@@ -114,9 +114,22 @@ export function createMastraAcpRunner({
|
|
|
114
114
|
}) : undefined,
|
|
115
115
|
})
|
|
116
116
|
active = { acp, controller }
|
|
117
|
-
const timer = setTimeout(() => { timedOut = true; controller.abort() }, maxCycleMs)
|
|
118
|
-
|
|
117
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort(); acp.connection.disconnect() }, maxCycleMs)
|
|
118
|
+
const requestedModel = cycleModel || model || ''
|
|
119
|
+
let selectedModel = ''
|
|
119
120
|
try {
|
|
121
|
+
if (requestedModel) {
|
|
122
|
+
const availableModels = await acp.getAvailableModels()
|
|
123
|
+
const resolution = resolveAvailableModel(requestedModel, availableModels)
|
|
124
|
+
selectedModel = resolution.selected
|
|
125
|
+
if (selectedModel) {
|
|
126
|
+
await acp.setModel(selectedModel)
|
|
127
|
+
if (selectedModel !== requestedModel) log(`model ${requestedModel} resolved to available ${selectedModel} [${resolution.reason}]`)
|
|
128
|
+
} else {
|
|
129
|
+
log(`model ${requestedModel} could not be negotiated because ${agent} did not advertise selectable models; using its session default`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
log(`running ${agent} cycle via Mastra ACP…${selectedModel || requestedModel ? ` [${selectedModel || requestedModel}]` : ' [runtime default]'}`)
|
|
120
133
|
const fullPrompt = systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt
|
|
121
134
|
for await (const event of acp.connection.promptStream(fullPrompt, controller.signal)) {
|
|
122
135
|
if (event.type === 'text') { outputText += event.text; continue }
|
|
@@ -152,7 +165,7 @@ export function createMastraAcpRunner({
|
|
|
152
165
|
didResultMessage = didChannelMessage && didRepoMutation
|
|
153
166
|
log(`${agent} cycle done via Mastra ACP (ok)`)
|
|
154
167
|
return {
|
|
155
|
-
type: 'result', subtype: 'ok', runtime: 'mastra-acp', outputText: outputText.trim(),
|
|
168
|
+
type: 'result', subtype: 'ok', runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
|
|
156
169
|
mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
|
|
157
170
|
didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage,
|
|
158
171
|
didMcpTaskRead, didMcpTaskUpdate,
|
|
@@ -162,7 +175,7 @@ export function createMastraAcpRunner({
|
|
|
162
175
|
const subtype = timedOut ? 'timeout' : canceled ? 'canceled' : 'error'
|
|
163
176
|
log(`${agent} cycle done via Mastra ACP (${subtype}${!canceled && error?.message ? ': ' + clean(error.message) : ''})`)
|
|
164
177
|
return {
|
|
165
|
-
type: 'result', subtype, runtime: 'mastra-acp', outputText: outputText.trim(),
|
|
178
|
+
type: 'result', subtype, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
|
|
166
179
|
mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
|
|
167
180
|
didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage,
|
|
168
181
|
didMcpTaskRead, didMcpTaskUpdate,
|
|
@@ -178,6 +191,7 @@ export function createMastraAcpRunner({
|
|
|
178
191
|
if (!active) return
|
|
179
192
|
active.controller.abort()
|
|
180
193
|
try { await active.acp.connection.cancel() } catch { /* already stopped */ }
|
|
194
|
+
active.acp.connection.disconnect()
|
|
181
195
|
}
|
|
182
196
|
|
|
183
197
|
log(`${agent} Mastra ACP runner ready${canCode ? ` [CODE workspace ${defaultCwd}]` : ' [CHAT-ONLY]'}`)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra']
|
|
2
|
+
const DEFAULT_EFFORT = 'medium'
|
|
3
|
+
|
|
4
|
+
const text = (value) => String(value ?? '').trim()
|
|
5
|
+
const lower = (value) => text(value).toLowerCase()
|
|
6
|
+
|
|
7
|
+
function parts(value) {
|
|
8
|
+
const raw = text(value)
|
|
9
|
+
const effortMatch = /\[([^\]]+)\]$/.exec(raw)
|
|
10
|
+
const withoutEffort = effortMatch ? raw.slice(0, effortMatch.index) : raw
|
|
11
|
+
const path = lower(withoutEffort).split('/').filter(Boolean)
|
|
12
|
+
const leaf = path.at(-1) || ''
|
|
13
|
+
const tokens = leaf.split(/[^a-z0-9]+/).filter(Boolean)
|
|
14
|
+
return { raw, id: lower(raw), base: lower(withoutEffort), leaf, tokens, provider: path.slice(0, -1).join('/'), effort: lower(effortMatch?.[1]) }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const effortDistance = (actual, preferred) => {
|
|
18
|
+
const actualIndex = EFFORTS.indexOf(actual)
|
|
19
|
+
const preferredIndex = EFFORTS.indexOf(preferred)
|
|
20
|
+
if (actualIndex < 0) return 20
|
|
21
|
+
return Math.abs(actualIndex - (preferredIndex < 0 ? EFFORTS.indexOf(DEFAULT_EFFORT) : preferredIndex))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function similarity(requested, candidate, preferredEffort) {
|
|
25
|
+
if (candidate.id === requested.id) return 100_000
|
|
26
|
+
let score = 0
|
|
27
|
+
if (candidate.base === requested.base || candidate.leaf === requested.leaf) score += 50_000
|
|
28
|
+
if (requested.provider && candidate.provider === requested.provider) score += 2_000
|
|
29
|
+
const requestedTokens = new Set(requested.tokens)
|
|
30
|
+
const shared = candidate.tokens.filter((token) => requestedTokens.has(token)).length
|
|
31
|
+
score += shared * 1_000
|
|
32
|
+
if (requested.leaf && (candidate.leaf.includes(requested.leaf) || requested.leaf.includes(candidate.leaf))) score += 10_000
|
|
33
|
+
let prefix = 0
|
|
34
|
+
while (prefix < requested.leaf.length && prefix < candidate.leaf.length && requested.leaf[prefix] === candidate.leaf[prefix]) prefix++
|
|
35
|
+
score += prefix * 10
|
|
36
|
+
score -= effortDistance(candidate.effort, requested.effort || preferredEffort) * 100
|
|
37
|
+
return score
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolveAvailableModel(requestedValue, availableModels = [], { preferredEffort = DEFAULT_EFFORT } = {}) {
|
|
41
|
+
const requested = parts(requestedValue)
|
|
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: '', reason: 'runtime-default', available: [] }
|
|
44
|
+
if (!requested.raw) return { requested: '', selected: '', reason: 'runtime-default', available: choices }
|
|
45
|
+
|
|
46
|
+
const ranked = choices.map((id, index) => {
|
|
47
|
+
const candidate = parts(id)
|
|
48
|
+
return { id, index, score: similarity(requested, candidate, preferredEffort) }
|
|
49
|
+
}).sort((a, b) => b.score - a.score || a.index - b.index)
|
|
50
|
+
const winner = ranked[0]
|
|
51
|
+
return {
|
|
52
|
+
requested: requested.raw,
|
|
53
|
+
selected: winner.id,
|
|
54
|
+
reason: lower(winner.id) === requested.id ? 'exact' : parts(winner.id).base === requested.base || parts(winner.id).leaf === requested.leaf ? 'family-effort' : 'nearest',
|
|
55
|
+
available: choices,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const CLAUDE_FAMILIES = ['sonnet', 'opus', 'haiku', 'fable']
|
|
60
|
+
|
|
61
|
+
export function resolveClaudeModel(requestedValue) {
|
|
62
|
+
const requested = lower(requestedValue)
|
|
63
|
+
if (!requested) return ''
|
|
64
|
+
const family = CLAUDE_FAMILIES.find((name) => requested === name || requested.startsWith('claude-') && requested.includes(name))
|
|
65
|
+
return family || text(requestedValue)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function claudeFallbackModels(selectedValue) {
|
|
69
|
+
const selected = lower(selectedValue)
|
|
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
|
+
}
|
|
75
|
+
|
package/src/watch.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { buildCodexMcpOverride } from './codex-config.mjs'
|
|
|
20
20
|
import { createCycleQueue } from './cycle-queue.mjs'
|
|
21
21
|
import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
|
|
22
22
|
import { createMastraAcpRunner } from './mastra-harness.mjs'
|
|
23
|
+
import { claudeFallbackModels, resolveClaudeModel } from './model-selection.mjs'
|
|
23
24
|
|
|
24
25
|
function findTicketWorktree(workspaceRoot, ticketId) {
|
|
25
26
|
if (!workspaceRoot || ticketId == null) return ''
|
|
@@ -613,7 +614,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
613
614
|
// The model the CURRENT session was spawned with. runCycle can pass a different
|
|
614
615
|
// model per cycle (cheap for chat, stronger for code) — a change recycles the
|
|
615
616
|
// session so the new model takes effect.
|
|
616
|
-
let sessionModel = model || null
|
|
617
|
+
let sessionModel = resolveClaudeModel(model) || null
|
|
617
618
|
let turnsThisSession = 0
|
|
618
619
|
let sessionStartedAt = 0
|
|
619
620
|
let resolveTurn = null
|
|
@@ -670,7 +671,8 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
670
671
|
if (!mcpConfig) { log('WARNING: no MCP config — the agent can react to events but has no tools to act. Re-connect with --mcp-url.') }
|
|
671
672
|
// The charter + creds ride in the system prompt (cacheable → not re-billed each
|
|
672
673
|
// cycle), leaving only the small per-cycle instruction in the user message.
|
|
673
|
-
const
|
|
674
|
+
const fallbackModels = claudeFallbackModels(sessionModel)
|
|
675
|
+
const base = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--strict-mcp-config', '--mcp-config', mcpConfig, ...(sessionModel ? ['--model', sessionModel, ...(fallbackModels.length ? ['--fallback-model', fallbackModels.join(',')] : [])] : []), ...(systemPrompt ? ['--append-system-prompt', systemPrompt] : [])]
|
|
674
676
|
const args = canCode ? [...base, '--allowedTools', ...CODE_TOOLS, '--disallowedTools', ...DENY_TOOLS] : [...base, '--allowedTools', 'mcp__openvisio-team__*']
|
|
675
677
|
const c = spawn(claude, args, { ...modelProcessOptions, cwd: workdir || undefined, stdio: ['pipe', 'pipe', 'inherit'] })
|
|
676
678
|
child = c
|
|
@@ -731,9 +733,11 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
731
733
|
return new Promise((resolve) => {
|
|
732
734
|
// A per-cycle model override (e.g. chat on a cheaper model than code) — a
|
|
733
735
|
// change means the warm session must be respawned with the new --model.
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
sessionModel =
|
|
736
|
+
const resolvedCycleModel = resolveClaudeModel(cycleModel)
|
|
737
|
+
if (resolvedCycleModel && resolvedCycleModel !== sessionModel) {
|
|
738
|
+
if (child) { log('model change ' + (sessionModel || 'default') + ' → ' + resolvedCycleModel + ' — recycling session'); try { child.kill() } catch { /* gone */ } child = null }
|
|
739
|
+
if (resolvedCycleModel !== cycleModel) log('model ' + cycleModel + ' resolved to Claude family alias ' + resolvedCycleModel)
|
|
740
|
+
sessionModel = resolvedCycleModel
|
|
737
741
|
}
|
|
738
742
|
if (child && (turnsThisSession >= MAX_TURNS || Date.now() - sessionStartedAt > SESSION_IDLE_MS)) {
|
|
739
743
|
log('recycling session (turns=' + turnsThisSession + ')')
|