openvisio-agent 0.18.1 → 0.18.2
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 +14 -0
- package/src/events.mjs +3 -0
- package/src/mcp-http.mjs +85 -0
- package/src/opencode-config.mjs +29 -0
- package/src/watch.mjs +42 -73
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
|
Backend/BYO watchers reconcile immediately whenever the process starts or the WebSocket connects. A direct MCP session uses the backend's actual tools (`list_agents`, `list_projects`, `list_tasks`, `list_task_types`, and `list_activity`) to recover assigned tasks and recent mention activity 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
60
|
|
|
61
|
+
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 `get_marching_orders`, `poll_inbox`, or `get_resource` tools.
|
|
62
|
+
|
|
61
63
|
Codex BYO agents follow the repository's normative runtime specification in `docs/CODEX_BYO_AGENT_SPEC.md`: one WebSocket identity, independent Sol reply/work lanes, authoritative `get_ticket` verification for assignments, REST-backed in-app activity, persistent replay suppression, and runtime evidence gates before completion. Maintainers must run `npm run certify` before publishing.
|
|
62
64
|
|
|
63
65
|
Claude uses Haiku for routine coordination and Sonnet for repository work. Codex uses `gpt-5.6-sol` for every cycle, including messages, mentions, triage, board movement, MCP calls, and coding. This intentionally favors reliability and consistent tool use over the cheaper Codex tiers.
|
package/package.json
CHANGED
package/scripts/certify.mjs
CHANGED
|
@@ -23,11 +23,14 @@ run('diff whitespace check', 'git', ['diff', '--check'], repo)
|
|
|
23
23
|
const watcher = readFileSync(join(root, 'src', 'watch.mjs'), 'utf8')
|
|
24
24
|
const events = readFileSync(join(root, 'src', 'events.mjs'), 'utf8')
|
|
25
25
|
const memory = readFileSync(join(root, 'src', 'memory.mjs'), 'utf8')
|
|
26
|
+
const mcpHttp = readFileSync(join(root, 'src', 'mcp-http.mjs'), 'utf8')
|
|
27
|
+
const opencodeConfig = readFileSync(join(root, 'src', 'opencode-config.mjs'), 'utf8')
|
|
26
28
|
const prPush = readFileSync(join(root, 'src', 'pr-push.mjs'), 'utf8')
|
|
27
29
|
const cli = readFileSync(join(root, 'bin', 'cli.mjs'), 'utf8')
|
|
28
30
|
const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
|
|
29
31
|
const activityHook = readFileSync(join(repo, 'frontend', 'hooks', 'useAgentActivity.ts'), 'utf8')
|
|
30
32
|
const taskHook = readFileSync(join(repo, 'frontend', 'hooks', 'useBackendTasks.ts'), 'utf8')
|
|
33
|
+
const liveTasks = readFileSync(join(repo, 'frontend', 'lib', 'collab', 'liveTasks.ts'), 'utf8')
|
|
31
34
|
const spec = readFileSync(join(repo, 'docs', 'CODEX_BYO_AGENT_SPEC.md'), 'utf8')
|
|
32
35
|
|
|
33
36
|
const assertions = [
|
|
@@ -59,9 +62,20 @@ const assertions = [
|
|
|
59
62
|
['frontend consumes typing event', activityHook.includes("'channel:agent:typing'")],
|
|
60
63
|
['frontend activity TTL distinguishes work from typing', activityHook.includes('thinking: 6_000') && activityHook.includes('typing: 5_000') && activityHook.includes('working: 30_000')],
|
|
61
64
|
['frontend consumes documented task comment events', taskHook.includes("'task:comment':") && taskHook.includes("'task:comment_updated':") && taskHook.includes("'task:comment_deleted':") && taskHook.includes("'task:comment_reacted':")],
|
|
65
|
+
['ticket comments load only on demand', !taskHook.includes('for (const task of tasks)') && taskHook.includes('loadedCommentIds.current.has(taskId)')],
|
|
66
|
+
['concurrent ticket comment loads share one request', taskHook.includes('commentRequests.current.get(requestKey)') && taskHook.includes('commentRequests.current.set(requestKey, request)')],
|
|
67
|
+
['backend ticket slugs render uppercase', liveTasks.includes("t.slug?.trim().toUpperCase()")],
|
|
62
68
|
['completion has an evidence failure gate', watcher.includes('WORK_CYCLE_FAILED')],
|
|
63
69
|
['OpenCode emits structured events for runtime evidence', watcher.includes("'--format', 'json'") && watcher.includes('opencodeEventEvidence(event)')],
|
|
70
|
+
['OpenCode API-key MCP disables OAuth probing', opencodeConfig.includes("oauth: false") && opencodeConfig.includes('timeout: 15_000')],
|
|
71
|
+
['OpenCode identities use isolated configs outside the code workspace', watcher.includes('opencodeRuntimeLayout({ cfgKey, workdir })') && watcher.includes("'--dir', workspace") && watcher.includes('OPENCODE_CONFIG: opencodeConfigPath') && watcher.includes('OPENCODE_CONFIG_CONTENT: JSON.stringify(opencodeConfig)')],
|
|
72
|
+
['OpenCode backend prompts forbid relay-only tools', watcher.includes('BACKEND MCP RULE') && watcher.includes('get_marching_orders, poll_inbox, and get_resource are relay-only')],
|
|
73
|
+
['OpenCode tool failures retain sanitized diagnostics', events.includes('toolError: toolError.replace') && watcher.includes("opencode tool '") && watcher.includes("split(redactKey).join('[redacted]')")],
|
|
64
74
|
['OpenCode acknowledgements cannot satisfy coding completion', watcher.includes("agent === 'codex' || agent === 'opencode'") && watcher.includes('releaseTaskForRetry(activeTaskRef, prompt)')],
|
|
75
|
+
['backend MCP accepts stateless initialize responses', mcpHttp.includes("mode = () => !initialized ? 'uninitialized' : sessionId ? 'stateful' : 'stateless'") && !watcher.includes('MCP initialize returned no session id')],
|
|
76
|
+
['MCP initialize is shared across concurrent startup probes', mcpHttp.includes('if (initializePromise) return initializePromise')],
|
|
77
|
+
['stateless tool errors do not cause initialize loops', mcpHttp.includes('if (hadSession && !retried')],
|
|
78
|
+
['backend introduction is watcher-owned for every runtime', watcher.includes('void announceIntroduction().then((delivered)')],
|
|
65
79
|
['Codex policy rejection is captured from stderr', watcher.includes("stdio: ['ignore', 'pipe', 'pipe']") && watcher.includes('forwardDiagnostic(d)') && watcher.includes('inspectDiagnostic(incoming)')],
|
|
66
80
|
['Codex recoverable subprocess diagnostics are not surfaced as activity', watcher.includes('shouldSuppressCodexDiagnostic(line)') && watcher.includes("forwardDiagnostic('', true)") && watcher.includes('RECOVER DEAD COMMAND SESSIONS')],
|
|
67
81
|
['policy rejection cannot be logged as successful', watcher.includes("subtype = policyBlock ? 'blocked'")],
|
package/src/events.mjs
CHANGED
|
@@ -89,6 +89,8 @@ export function opencodeEventEvidence(event) {
|
|
|
89
89
|
const status = String(state.status ?? part.status ?? '').toLowerCase()
|
|
90
90
|
const failed = /error|failed|denied|rejected/.test(status) || state.error != null || part.error != null
|
|
91
91
|
const completed = !failed && (!status || /completed|success|succeeded|ok/.test(status))
|
|
92
|
+
const rawToolError = state.error ?? part.error
|
|
93
|
+
const toolError = rawToolError == null ? '' : (typeof rawToolError === 'string' ? rawToolError : JSON.stringify(rawToolError))
|
|
92
94
|
const input = state.input && typeof state.input === 'object' ? state.input : (part.input && typeof part.input === 'object' ? part.input : {})
|
|
93
95
|
const command = String(input.command ?? input.cmd ?? '')
|
|
94
96
|
|
|
@@ -107,6 +109,7 @@ export function opencodeEventEvidence(event) {
|
|
|
107
109
|
...(mcpTool ? { mcpTool } : {}),
|
|
108
110
|
failed,
|
|
109
111
|
completed,
|
|
112
|
+
...(failed && toolError ? { toolError: toolError.replace(/\s+/g, ' ').slice(0, 500) } : {}),
|
|
110
113
|
didCode: completed && (mutationTool || bashTool || codebaseMutation),
|
|
111
114
|
didRepoMutation: completed && (mutationTool || codebaseMutation || (bashTool && commandMutation)),
|
|
112
115
|
didMcpTaskRead: completed && /^(?:get_ticket|list_tasks|list_task_types)$/.test(mcpTool),
|
package/src/mcp-http.mjs
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const parsePayload = async (res) => {
|
|
2
|
+
const body = await res.text()
|
|
3
|
+
const data = body.split(/\r?\n/).filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim()).pop()
|
|
4
|
+
try { return JSON.parse(data || body || '{}') }
|
|
5
|
+
catch { throw new Error('MCP returned a non-JSON response') }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Streamable HTTP permits both stateful servers (initialize returns
|
|
9
|
+
// Mcp-Session-Id) and stateless servers (no session header). The backend agent
|
|
10
|
+
// MCP is deployed in both forms, so absence of a session id is a transport mode,
|
|
11
|
+
// not an initialization failure.
|
|
12
|
+
export function createMcpHttpClient({ url, apiKey, identifier, clientVersion, fetchImpl = fetch, log = () => {} }) {
|
|
13
|
+
let initialized = false
|
|
14
|
+
let sessionId = ''
|
|
15
|
+
let rpcId = 0
|
|
16
|
+
let initializePromise = null
|
|
17
|
+
|
|
18
|
+
const post = (message, withSession = true) => fetchImpl(url, {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: {
|
|
21
|
+
'content-type': 'application/json',
|
|
22
|
+
accept: 'application/json, text/event-stream',
|
|
23
|
+
'x-agent-api-key': apiKey,
|
|
24
|
+
'x-agent-identifier': identifier,
|
|
25
|
+
...(withSession && sessionId ? { 'mcp-session-id': sessionId } : {}),
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify(message),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const reset = () => { initialized = false; sessionId = '' }
|
|
31
|
+
|
|
32
|
+
const initialize = () => {
|
|
33
|
+
if (initialized) return Promise.resolve()
|
|
34
|
+
if (initializePromise) return initializePromise
|
|
35
|
+
initializePromise = (async () => {
|
|
36
|
+
const res = await post({
|
|
37
|
+
jsonrpc: '2.0',
|
|
38
|
+
id: ++rpcId,
|
|
39
|
+
method: 'initialize',
|
|
40
|
+
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: clientVersion } },
|
|
41
|
+
}, false)
|
|
42
|
+
if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
|
|
43
|
+
const payload = await parsePayload(res)
|
|
44
|
+
if (payload.error) throw new Error(`MCP initialize: ${payload.error.message || 'protocol error'}`)
|
|
45
|
+
sessionId = res.headers.get('mcp-session-id') || ''
|
|
46
|
+
|
|
47
|
+
const ready = await post({ jsonrpc: '2.0', method: 'notifications/initialized' })
|
|
48
|
+
if (!ready.ok) { reset(); throw new Error('MCP initialized HTTP ' + ready.status) }
|
|
49
|
+
initialized = true
|
|
50
|
+
if (!sessionId) log('MCP initialized in stateless mode (no session id required)')
|
|
51
|
+
})().finally(() => { initializePromise = null })
|
|
52
|
+
return initializePromise
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const callTool = async (name, args = {}, retried = false) => {
|
|
56
|
+
await initialize()
|
|
57
|
+
const hadSession = !!sessionId
|
|
58
|
+
const res = await post({
|
|
59
|
+
jsonrpc: '2.0',
|
|
60
|
+
id: ++rpcId,
|
|
61
|
+
method: 'tools/call',
|
|
62
|
+
params: { name, arguments: { ...args, agent_api_key: apiKey, agent_identifier: identifier } },
|
|
63
|
+
})
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
// Only a stateful transport can have an expired session. A stateless 4xx
|
|
66
|
+
// belongs to the tool request itself and must not trigger an initialize loop.
|
|
67
|
+
if (hadSession && !retried && [400, 404, 409, 410].includes(res.status)) {
|
|
68
|
+
reset()
|
|
69
|
+
return callTool(name, args, true)
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`MCP ${name} HTTP ${res.status}`)
|
|
72
|
+
}
|
|
73
|
+
const payload = await parsePayload(res)
|
|
74
|
+
if (payload.error) throw new Error(`MCP ${name}: ${payload.error.message || 'tool error'}`)
|
|
75
|
+
const result = payload.result ?? payload
|
|
76
|
+
if (result?.isError) {
|
|
77
|
+
const detail = String(result.content?.find?.((item) => item?.type === 'text')?.text || 'tool error').split(apiKey).join('[redacted]')
|
|
78
|
+
throw new Error(`MCP ${name}: ${detail}`)
|
|
79
|
+
}
|
|
80
|
+
return result
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const mode = () => !initialized ? 'uninitialized' : sessionId ? 'stateful' : 'stateless'
|
|
84
|
+
return { callTool, initialize, reset, mode }
|
|
85
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { OV_DIR, slugify } from './lib.mjs'
|
|
3
|
+
|
|
4
|
+
export function opencodeRuntimeLayout({ cfgKey, workdir, baseDir = OV_DIR }) {
|
|
5
|
+
const agentKey = slugify(String(cfgKey || 'agent')) || 'agent'
|
|
6
|
+
const configDir = join(baseDir, 'opencode-' + agentKey)
|
|
7
|
+
return {
|
|
8
|
+
configDir,
|
|
9
|
+
configPath: join(configDir, 'opencode.json'),
|
|
10
|
+
workspace: workdir || configDir,
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildOpencodeConfig({ mcpUrl, mcpHeaders }) {
|
|
15
|
+
if (!mcpUrl) return null
|
|
16
|
+
return {
|
|
17
|
+
$schema: 'https://opencode.ai/config.json',
|
|
18
|
+
mcp: {
|
|
19
|
+
'openvisio-team': {
|
|
20
|
+
type: 'remote',
|
|
21
|
+
url: mcpUrl,
|
|
22
|
+
enabled: true,
|
|
23
|
+
oauth: false,
|
|
24
|
+
timeout: 15_000,
|
|
25
|
+
...(mcpHeaders && Object.keys(mcpHeaders).length ? { headers: mcpHeaders } : {}),
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/watch.mjs
CHANGED
|
@@ -13,6 +13,8 @@ import { connectAgentWs, assertWebSocket } from './ws.mjs'
|
|
|
13
13
|
import { agentStateRequest, buildTaskCompletionReport, codexPolicyBlock, mentionDedupeKeys, normalizeRenderedMessageText, opencodeEventEvidence, renderedAgentMessages, requestTargetsLaterAgent, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted } from './events.mjs'
|
|
14
14
|
import { createByoMemoryGraph } from './memory.mjs'
|
|
15
15
|
import { repositoryHasPrPushAuthorization } from './pr-push.mjs'
|
|
16
|
+
import { createMcpHttpClient } from './mcp-http.mjs'
|
|
17
|
+
import { buildOpencodeConfig, opencodeRuntimeLayout } from './opencode-config.mjs'
|
|
16
18
|
|
|
17
19
|
// Behaviour prompts. The openvisio-team MCP bridge requires the agent's
|
|
18
20
|
// credentials as ARGUMENTS on every tool call — those are injected at runtime by
|
|
@@ -288,25 +290,25 @@ export async function runWatch({ flags }) {
|
|
|
288
290
|
// ── opencode cycle runner ─────────────────────────────────────────────────────
|
|
289
291
|
// opencode (opencode.ai) has no persistent stream-json protocol like Claude Code,
|
|
290
292
|
// so each cycle is a headless `opencode run <prompt> --auto [--model provider/model]`.
|
|
291
|
-
// The openvisio-team MCP is declared in
|
|
292
|
-
//
|
|
293
|
+
// The openvisio-team MCP is declared in a private per-agent config. The actual
|
|
294
|
+
// code workspace is passed through --dir, so two agents never overwrite each
|
|
295
|
+
// other's MCP identity even when they work in the same repository.
|
|
296
|
+
// `--auto` approves tool use non-interactively.
|
|
293
297
|
// Same { runCycle, canCode } contract as the Claude runner.
|
|
294
298
|
function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt }) {
|
|
295
|
-
|
|
296
|
-
// per-agent dir for chat-only agents.
|
|
297
|
-
const cwd = workdir || join(OV_DIR, 'opencode-' + (cfgKey || 'agent'))
|
|
299
|
+
const { configDir, configPath: opencodeConfigPath, workspace } = opencodeRuntimeLayout({ cfgKey, workdir })
|
|
298
300
|
const bin = onPath('opencode') || 'opencode'
|
|
301
|
+
const redactKey = String(mcpHeaders?.['x-agent-api-key'] || '')
|
|
302
|
+
const opencodeConfig = buildOpencodeConfig({ mcpUrl, mcpHeaders })
|
|
299
303
|
let configured = false
|
|
300
304
|
const ensureConfig = () => {
|
|
301
305
|
if (configured) return
|
|
302
306
|
configured = true
|
|
303
307
|
try {
|
|
304
|
-
mkdirSync(
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
mcp: { 'openvisio-team': { type: 'remote', url: mcpUrl, enabled: true, ...(mcpHeaders && Object.keys(mcpHeaders).length ? { headers: mcpHeaders } : {}) } },
|
|
309
|
-
}, true)
|
|
308
|
+
mkdirSync(configDir, { recursive: true })
|
|
309
|
+
mkdirSync(workspace, { recursive: true })
|
|
310
|
+
if (opencodeConfig) {
|
|
311
|
+
writeJson(opencodeConfigPath, opencodeConfig, true)
|
|
310
312
|
} else {
|
|
311
313
|
log('WARNING: no --mcp-url — opencode has no openvisio-team tools to act with. Re-connect with --mcp-url.')
|
|
312
314
|
}
|
|
@@ -322,7 +324,7 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
322
324
|
const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
|
|
323
325
|
// JSON mode is the evidence boundary. Formatted stdout only tells us that
|
|
324
326
|
// OpenCode exited; raw events tell us which tools actually completed.
|
|
325
|
-
const args = ['run', full, '--auto', '--format', 'json', ...(m ? ['--model', m] : [])]
|
|
327
|
+
const args = ['run', full, '--auto', '--format', 'json', '--dir', workspace, ...(m ? ['--model', m] : [])]
|
|
326
328
|
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didMcpTaskRead = false, didMcpTaskUpdate = false
|
|
327
329
|
let outputText = '', jsonlBuffer = ''
|
|
328
330
|
const mcpCalls = new Set(), mcpErrors = new Set(), runtimeErrors = new Set()
|
|
@@ -350,6 +352,10 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
350
352
|
if (evidence.runtimeError) runtimeErrors.add(evidence.runtimeError)
|
|
351
353
|
if (!evidence.tool) return
|
|
352
354
|
if (debug) log(' → tool ' + evidence.tool + (evidence.failed ? ' (failed)' : evidence.completed ? ' (completed)' : ''))
|
|
355
|
+
if (evidence.failed && evidence.toolError) {
|
|
356
|
+
const safeError = (redactKey ? String(evidence.toolError).split(redactKey).join('[redacted]') : String(evidence.toolError)).replace(/\s+/g, ' ').slice(0, 240)
|
|
357
|
+
log(' ✗ opencode tool ' + evidence.tool + ': ' + safeError)
|
|
358
|
+
}
|
|
353
359
|
try { onTool && onTool(evidence.tool) } catch { /* activity is best-effort */ }
|
|
354
360
|
if (evidence.mcpTool) {
|
|
355
361
|
mcpCalls.add(evidence.mcpTool)
|
|
@@ -370,7 +376,18 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
370
376
|
}, maxCycleMs)
|
|
371
377
|
log('running opencode cycle…' + (m ? ' [' + m + ']' : ''))
|
|
372
378
|
try {
|
|
373
|
-
child = spawn(bin, args, {
|
|
379
|
+
child = spawn(bin, args, {
|
|
380
|
+
cwd: configDir,
|
|
381
|
+
env: {
|
|
382
|
+
...process.env,
|
|
383
|
+
OPENCODE_CONFIG: opencodeConfigPath,
|
|
384
|
+
// Inline config has higher precedence than a project opencode.json.
|
|
385
|
+
// This prevents a stale generated workspace file from replacing this
|
|
386
|
+
// agent's URL or credentials while keeping normal project settings.
|
|
387
|
+
...(opencodeConfig ? { OPENCODE_CONFIG_CONTENT: JSON.stringify(opencodeConfig) } : {}),
|
|
388
|
+
},
|
|
389
|
+
stdio: ['ignore', 'pipe', 'inherit'],
|
|
390
|
+
})
|
|
374
391
|
child.stdout?.on('data', (data) => {
|
|
375
392
|
jsonlBuffer += String(data)
|
|
376
393
|
const lines = jsonlBuffer.split('\n')
|
|
@@ -389,7 +406,7 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
389
406
|
})
|
|
390
407
|
}
|
|
391
408
|
|
|
392
|
-
log('opencode runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' +
|
|
409
|
+
log('opencode runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + workspace + '; isolated cfg ' + configDir + ']' : ' [CHAT-ONLY; isolated cfg ' + configDir + ']'))
|
|
393
410
|
return { runCycle, canCode }
|
|
394
411
|
}
|
|
395
412
|
|
|
@@ -670,7 +687,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
670
687
|
const canCode = !!workdir
|
|
671
688
|
// The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
|
|
672
689
|
// agent_identifier + agent_api_key as arguments. Hand them over up front.
|
|
673
|
-
const credNote = `AUTH: the openvisio-team tools REQUIRE two arguments on EVERY call — agent_identifier: "${identifier}" and agent_api_key: "${apiKey}". Include BOTH on every openvisio-team tool call. Use ONLY names shown in the current tool list. On backend MCP, discover and update work with list_agents, list_projects, list_tasks, get_ticket, update_ticket, comment_ticket, and list_activity
|
|
690
|
+
const credNote = `AUTH: the openvisio-team tools REQUIRE two arguments on EVERY call — agent_identifier: "${identifier}" and agent_api_key: "${apiKey}". Include BOTH on every openvisio-team tool call. Use ONLY names shown in the current tool list. On backend MCP, discover and update work with list_agents, list_projects, list_tasks, list_task_types, get_ticket, update_ticket, comment_ticket, list_channels, list_message_thread, and list_activity as applicable. get_marching_orders, poll_inbox, and get_resource are relay-only and are NOT available here; never call them. Tools may be namespaced — call whichever names actually appear. The credentials are given here; do NOT hunt for them. Bash/git/gh ARE for code work; this rule only forbids searching for keys.`
|
|
674
691
|
// The STATIC charter + creds are the session system prompt (cached, billed once),
|
|
675
692
|
// NOT re-sent in every cycle's user message — the big token saving.
|
|
676
693
|
const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
|
|
@@ -687,8 +704,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
687
704
|
const codexPushGuide = agent === 'codex' && canCode
|
|
688
705
|
? '\n\nCODEX PR DELIVERY: first inspect the OpenVisio MCP tools. When list_codebases, create_codebase_branch, create_codebase_commit (or write_codebase_file), and create_pull_request are available, use that authenticated linked-codebase flow to create the agent/* branch, publish the verified changed files, and open the PR. This is the preferred path and requires no local git push. If those tools are unavailable for the repository, do not run git push directly. From the repository run `openvisio-agent push-pr-branch`. It is a user-authorized constrained fallback that can only push HEAD to the matching agent/* branch on the exact authorized origin. If it reports OPENVISIO_PR_PUSH_AUTH_REQUIRED, do not retry or route around it. Report the one-time command `openvisio-agent authorize-pr-push` as the blocker.'
|
|
689
706
|
: ''
|
|
690
|
-
const
|
|
691
|
-
const
|
|
707
|
+
const backendToolRule = 'BACKEND MCP RULE: the event and watcher already provide the work source. Never call get_marching_orders, poll_inbox, get_resource, or other relay-only tools; they are not exposed by the backend MCP. Use only names present in the current openvisio-team tool list. For discovery use list_agents, list_projects, list_tasks, list_task_types, list_activity, get_ticket, and list_channels as applicable.'
|
|
708
|
+
const fullPrompt = (canCode ? CODE_FULL + codexPushGuide : 'Handle the supplied verified backend ticket with the available OpenVisio tools. Update or comment on the ticket as requested, do not claim repository work in chat-only mode, and stop after the verified action.') + '\n\n' + backendToolRule
|
|
709
|
+
const fastPrompt = (canCode ? CODE_FAST : CYCLE_FAST) + '\n\n' + backendToolRule
|
|
710
|
+
const coordinatePrompt = COORDINATE + '\n\n' + backendToolRule
|
|
692
711
|
// Live model state — changeable at runtime by the in-chat `/model` command.
|
|
693
712
|
// codeModel drives full/sweep cycles; chatModel (if set) the lighter fast/intro
|
|
694
713
|
// ones, so routine chatter can run cheaper than real code work.
|
|
@@ -768,54 +787,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
768
787
|
let lastTaskTriggeredAt = 0
|
|
769
788
|
let lastInboxSignature = ''
|
|
770
789
|
let selfAgentId = null
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
const mcpPayload = async (res) => {
|
|
775
|
-
const body = await res.text()
|
|
776
|
-
const data = body.split(/\r?\n/).filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim()).pop()
|
|
777
|
-
return JSON.parse(data || body || '{}')
|
|
778
|
-
}
|
|
779
|
-
const mcpPost = async (message, withSession = true) => {
|
|
780
|
-
const res = await fetch(mcpUrl, {
|
|
781
|
-
method: 'POST',
|
|
782
|
-
headers: {
|
|
783
|
-
'content-type': 'application/json',
|
|
784
|
-
accept: 'application/json, text/event-stream',
|
|
785
|
-
'x-agent-api-key': apiKey,
|
|
786
|
-
'x-agent-identifier': identifier,
|
|
787
|
-
...(withSession && mcpSessionId ? { 'mcp-session-id': mcpSessionId } : {}),
|
|
788
|
-
},
|
|
789
|
-
body: JSON.stringify(message),
|
|
790
|
-
})
|
|
791
|
-
return res
|
|
792
|
-
}
|
|
793
|
-
const ensureMcpSession = async () => {
|
|
794
|
-
if (mcpSessionId) return
|
|
795
|
-
const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.18.1' } } }, false)
|
|
796
|
-
if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
|
|
797
|
-
await mcpPayload(res)
|
|
798
|
-
mcpSessionId = res.headers.get('mcp-session-id') || ''
|
|
799
|
-
if (!mcpSessionId) throw new Error('MCP initialize returned no session id')
|
|
800
|
-
const ready = await mcpPost({ jsonrpc: '2.0', method: 'notifications/initialized' })
|
|
801
|
-
if (!ready.ok) throw new Error('MCP initialized HTTP ' + ready.status)
|
|
802
|
-
}
|
|
803
|
-
const callMcpTool = async (name, args = {}, retried = false) => {
|
|
804
|
-
await ensureMcpSession()
|
|
805
|
-
const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'tools/call', params: { name, arguments: { ...args, agent_api_key: apiKey, agent_identifier: identifier } } })
|
|
806
|
-
if (!res.ok) {
|
|
807
|
-
if (!retried && (res.status === 400 || res.status === 404)) { mcpSessionId = ''; return callMcpTool(name, args, true) }
|
|
808
|
-
throw new Error(`MCP ${name} HTTP ${res.status}`)
|
|
809
|
-
}
|
|
810
|
-
const payload = await mcpPayload(res)
|
|
811
|
-
if (payload.error) throw new Error(`MCP ${name}: ${payload.error.message || 'tool error'}`)
|
|
812
|
-
const result = payload.result ?? payload
|
|
813
|
-
if (result?.isError) {
|
|
814
|
-
const detail = String(result.content?.find?.((c) => c?.type === 'text')?.text || 'tool error').split(apiKey).join('[redacted]')
|
|
815
|
-
throw new Error(`MCP ${name}: ${detail}`)
|
|
816
|
-
}
|
|
817
|
-
return result
|
|
818
|
-
}
|
|
790
|
+
const mcpClient = createMcpHttpClient({ url: mcpUrl, apiKey, identifier, clientVersion: '0.18.2', log })
|
|
791
|
+
const callMcpTool = (name, args = {}) => mcpClient.callTool(name, args)
|
|
819
792
|
const toolData = (result) => {
|
|
820
793
|
const text = result?.content?.find?.((c) => c?.type === 'text')?.text
|
|
821
794
|
if (typeof text !== 'string') return result?.structuredContent ?? result ?? {}
|
|
@@ -1144,14 +1117,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1144
1117
|
}
|
|
1145
1118
|
} else if (!mentionActivity.length) lastInboxSignature = ''
|
|
1146
1119
|
} catch (e) {
|
|
1147
|
-
|
|
1120
|
+
mcpClient.reset()
|
|
1148
1121
|
log('backlog reconciliation failed: ' + (e && e.message ? e.message : e))
|
|
1149
1122
|
} finally { backlogProbeBusy = false }
|
|
1150
1123
|
}
|
|
1151
1124
|
|
|
1152
1125
|
// Higher rank wins when coalescing cycles requested while one is running.
|
|
1153
1126
|
const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
|
|
1154
|
-
const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ?
|
|
1127
|
+
const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? coordinatePrompt : fastPrompt
|
|
1155
1128
|
// Codex and OpenCode expose structured action streams. Require runtime facts
|
|
1156
1129
|
// from those streams before accepting a full coding cycle. Claude's evidence
|
|
1157
1130
|
// shape is different and remains on its existing completion path.
|
|
@@ -1473,8 +1446,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1473
1446
|
const replyDelivery = guardedDelivery(codingMention ? 'result' : 'reply', !codingMention)
|
|
1474
1447
|
const ctx = cid != null
|
|
1475
1448
|
? replyDelivery
|
|
1476
|
-
? `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. ${codingMention ? 'Complete the repository work and verification first.' : 'Answer the request.'} Do NOT call post_message; it is intentionally unavailable. Return only the final 1-3 sentence reply as your final answer. The watcher will read the real thread, check its persistent memory graph, and render that answer at most once.${who ? ` To mention the requester, use their exact full name "@${who}".` : ''}
|
|
1477
|
-
: `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. ${codingMention ? 'This is repository work: complete the coding flow first, then send' : 'Send'} EXACTLY ONE reply with post_message: arguments: channel_id ${cid}${threadRoot != null ? `, parent_id ${threadRoot} (reply IN THAT THREAD, do not start a new top-level message)` : ''}, plus agent_identifier + agent_api_key from the AUTH line above, and a 1-3 sentence reply. Compose the whole answer, then post it ONCE. Do not post a first reply and then a revised version. FIRST read the recent messages in this thread: if you already answered this, or another agent was the one addressed, do NOT post at all. Be sure of your answer before sending.${who ? ` To @mention them back, write their EXACT full name "@${who}". A mention only links when the name matches exactly.` : ''}
|
|
1449
|
+
? `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. ${codingMention ? 'Complete the repository work and verification first.' : 'Answer the request.'} Do NOT call post_message; it is intentionally unavailable. Return only the final 1-3 sentence reply as your final answer. The watcher will read the real thread, check its persistent memory graph, and render that answer at most once.${who ? ` To mention the requester, use their exact full name "@${who}".` : ''} The complete message is already here; do not call get_resource, get_marching_orders, or poll_inbox.`
|
|
1450
|
+
: `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. ${codingMention ? 'This is repository work: complete the coding flow first, then send' : 'Send'} EXACTLY ONE reply with post_message: arguments: channel_id ${cid}${threadRoot != null ? `, parent_id ${threadRoot} (reply IN THAT THREAD, do not start a new top-level message)` : ''}, plus agent_identifier + agent_api_key from the AUTH line above, and a 1-3 sentence reply. Compose the whole answer, then post it ONCE. Do not post a first reply and then a revised version. FIRST read the recent messages in this thread: if you already answered this, or another agent was the one addressed, do NOT post at all. Be sure of your answer before sending.${who ? ` To @mention them back, write their EXACT full name "@${who}". A mention only links when the name matches exactly.` : ''} The complete message is already here. Do not call get_resource, get_marching_orders, or poll_inbox, and after your single reply, STOP.`
|
|
1478
1451
|
: undefined
|
|
1479
1452
|
if (codingMention) {
|
|
1480
1453
|
if (agent === 'codex' && cid != null) {
|
|
@@ -1509,12 +1482,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1509
1482
|
// Workspace ethics: a one-time hello the FIRST time this agent ever connects.
|
|
1510
1483
|
const introMarker = join(OV_DIR, 'intro-' + slugify(identifier) + '.done')
|
|
1511
1484
|
if (!existsSync(introMarker)) {
|
|
1512
|
-
if (agent !== 'codex') {
|
|
1513
|
-
try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
|
|
1514
|
-
}
|
|
1515
1485
|
log('first connection — introducing self to the workspace')
|
|
1516
1486
|
introTimer = setTimeout(() => {
|
|
1517
|
-
if (agent !== 'codex') { void drain('intro'); return }
|
|
1518
1487
|
void announceIntroduction().then((delivered) => {
|
|
1519
1488
|
if (!delivered) return
|
|
1520
1489
|
try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
|