openvisio-agent 0.25.12 → 0.25.14
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/CHANGELOG.md +16 -0
- package/README.md +6 -2
- package/USER_GUIDE.md +9 -5
- package/package.json +1 -1
- package/scenarios/conversation-lifecycle.scenarios.mjs +158 -0
- package/scenarios/conversation.scenarios.mjs +55 -0
- package/scenarios/workspace.scenarios.mjs +10 -4
- package/scripts/certify.mjs +5 -7
- package/src/activity-reporter.mjs +28 -40
- package/src/context-search.mjs +5 -1
- package/src/context-service.mjs +6 -5
- package/src/conversation-context.mjs +139 -0
- package/src/events.mjs +5 -7
- package/src/mastra-harness.mjs +14 -5
- package/src/peer-turns.mjs +14 -0
- package/src/report-routing.mjs +10 -0
- package/src/runtime-control.mjs +1 -1
- package/src/studio-connect.mjs +55 -0
- package/src/studio-server.mjs +21 -1
- package/src/thread-context.mjs +1 -1
- package/src/tool-policy.mjs +3 -0
- package/src/watch.mjs +138 -39
- package/studio/app.mjs +62 -5
- package/studio/guide.html +1 -1
- package/studio/index.html +19 -1
- package/studio/style.css +4 -4
package/src/watch.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { createContextService } from './context-service.mjs'
|
|
|
18
18
|
import { contextToolGuide } from './context-tools.mjs'
|
|
19
19
|
import { loadProjectTickets, needsWorkContinuation } from './work-recovery.mjs'
|
|
20
20
|
import { toolWithoutCredentialInputs } from './codex-mcp-proxy.mjs'
|
|
21
|
-
import { compactAgentMessage } from './tool-policy.mjs'
|
|
21
|
+
import { compactAgentMessage, ITEM_REFERENCE_GUIDANCE } from './tool-policy.mjs'
|
|
22
22
|
import { repositoryHasPrPushAuthorization, configurePrPublishing } from './pr-push.mjs'
|
|
23
23
|
import { createMcpHttpClient } from './mcp-http.mjs'
|
|
24
24
|
import { createCycleQueue } from './cycle-queue.mjs'
|
|
@@ -30,6 +30,8 @@ import { releaseAuthorizedPause } from './authorization-resume.mjs'
|
|
|
30
30
|
import { dedicatedChannel, repliesAfterSource } from './channel-routing.mjs'
|
|
31
31
|
import { createTaskTypeLookup, loadProjectTaskTypes } from './task-types.mjs'
|
|
32
32
|
import { createThreadContextResolver } from './thread-context.mjs'
|
|
33
|
+
import { compileConversationContext, createConversationReader, conversationAuthor, checkedConversationResult } from './conversation-context.mjs'
|
|
34
|
+
import { admitPeerTurn } from './peer-turns.mjs'
|
|
33
35
|
import { assignmentRequest, assignmentStatusOnly, routeAssignments } from './assignment-routing.mjs'
|
|
34
36
|
import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
|
|
35
37
|
import { createMastraAcpRunner } from './mastra-harness.mjs'
|
|
@@ -38,6 +40,7 @@ import { createAgentJournal } from './agent-journal.mjs'
|
|
|
38
40
|
import { readModelSettings } from './model-settings.mjs'
|
|
39
41
|
import { startStudioInBackground } from './studio-cli.mjs'
|
|
40
42
|
import { toolData } from './team-data.mjs'
|
|
43
|
+
import { REPORT_ROUTING_PROMPT, reportChannelSelection } from './report-routing.mjs'
|
|
41
44
|
import { runtimeProvider, supportedRuntimeList } from './runtime-providers.mjs'
|
|
42
45
|
|
|
43
46
|
function runtimeJournal(options, supplied) {
|
|
@@ -80,6 +83,7 @@ function findTicketWorktree(workspaceRoot, ticketId) {
|
|
|
80
83
|
// answering a question meant for a DIFFERENT agent, and claiming something works
|
|
81
84
|
// then walking it back.
|
|
82
85
|
const REPLY_DISCIPLINE = [
|
|
86
|
+
ITEM_REFERENCE_GUIDANCE,
|
|
83
87
|
'REPLY DISCIPLINE — read the recent messages FIRST, then decide whether to speak at all:',
|
|
84
88
|
' • FIRST-PERSON VOICE. Speak as yourself: use “I”, “I\'m”, and “my”. Never refer to yourself by your agent name or in the third person, and never restate your own name in introductions, acknowledgements, progress, blockers, or results. The app already shows who sent the message. Sound like a warm, accountable teammate, not a status bot.',
|
|
85
89
|
' • IS IT FOR YOU? Act ONLY on messages addressed to YOU — an @mention of your exact name, a direct question to you, or a reply to something YOU said or did. If a DIFFERENT agent or person was @mentioned or asked to do something, STAY OUT: do not answer for them and do not pick up their task. When it is not yours, posting nothing is the correct move.',
|
|
@@ -89,9 +93,9 @@ const REPLY_DISCIPLINE = [
|
|
|
89
93
|
' • USE RECALL, NEVER INVENT IT. Before answering a context-dependent question, search the visible thread and use any available history, search, docs, or recall tools. Reuse verified context instead of asking the user to repeat it. If no record exists, say plainly "I don\'t have a record of that". Never fabricate past events, conversations, results, links, PR numbers, deploy URLs, or figures.',
|
|
90
94
|
' • LOOK UP ASSIGNED WORK. If someone says they assigned you a task, asks which task is yours, or asks for its status, check the live board yourself with list_projects + list_tasks and then get_ticket as needed. Use list_agents only if assignment data requires a numeric identity lookup. Match assignments to your authenticated agent identity. Do not ask the teammate for a project slug, ticket slug, or numeric id before trying those MCP tools; ask only if the live lookup fails or returns genuinely ambiguous matches.',
|
|
91
95
|
' • ASSIGNEE IDS ARE HUMAN IDS. On task mutations, assignee_id means a human user/member. agent_id means a registered agent. Never put an agent id in assignee_id, never put a human id in agent_id, and never send both. To assign work to yourself or another agent, resolve the registered agent and use agent_id.',
|
|
92
|
-
' • TICKET SLUGS, NEVER DATABASE IDS. In every human-facing channel message, ticket comment, PR description, summary, blocker, and result, reference a ticket by the exact project-scoped slug returned by get_ticket/list_tasks (for example, `OVS-57`). Numeric project_id and ticket_id values
|
|
96
|
+
' • TICKET SLUGS, NEVER DATABASE IDS. In every human-facing channel message, ticket comment, PR description, summary, blocker, and result, reference a ticket by the exact project-scoped slug returned by get_ticket/list_tasks (for example, `OVS-57`). Numeric project_id and ticket_id values belong only in MCP arguments or verified link destinations: never write `#57`, `ticket 57`, or expose a database id to teammates. If the backend omits the slug, use the ticket title or say “the ticket”; do not invent a slug.',
|
|
93
97
|
' • CLOSE CONCERNS. Never leave a concern, direct question, correction, or blocker addressed to you without a clear response. Acknowledge the concern, act if you can, then report the verified result. If blocked, name the blocker and the exact next action or owner in one message.',
|
|
94
|
-
' • HONOR CHANNEL DESTINATIONS. Reply to an existing request in its original thread. When a teammate explicitly asks you to send, post, announce, or share something in another channel, resolve that exact destination from live list_channels and post there.
|
|
98
|
+
' • HONOR CHANNEL DESTINATIONS. Reply to an existing request in its original thread. When a teammate explicitly asks you to send, post, announce, or share something in another channel, resolve that exact destination from live list_channels and post there. For a task result without a source thread or explicit destination, choose the existing channel that best fits the project, subject, and audience; a dedicated agent channel is not required. Never invent a channel id or silently substitute a different explicitly requested channel.',
|
|
95
99
|
' • KEEP MESSAGES COMPACT. Lead with the answer, result, decision, or blocker. Default to 1-4 short sentences or at most 6 brief bullets, normally under 600 characters and never over 1,200. Do not restate the prompt, narrate routine tool use, dump raw logs, or repeat context already visible in the thread. Task descriptions may use up to 2,400 characters when acceptance details require it.',
|
|
96
100
|
' • END AGENT-TO-AGENT TURNS. Answer one concrete peer-agent request once, without a reciprocal question, invitation, thank-you exchange, or status echo. Then wait for a human or a genuinely new human-directed request. Do not keep a conversation alive merely because another agent replied.',
|
|
97
101
|
' • WRITE PLAINLY. Prefer short sentences, commas, periods, and colons. Avoid em dashes except when reproducing quoted text.',
|
|
@@ -135,7 +139,7 @@ const CODE_CHARTER = [
|
|
|
135
139
|
'',
|
|
136
140
|
'WORK ETHIC — how a reliable teammate behaves (this is the difference between useful and ignored):',
|
|
137
141
|
' 1. CLOSE THE LOOP in THIS cycle. Never say "I\'ll do X" and stop. If you commit to something, do it NOW — the human must never have to remind you to circle back. Sending an intent or progress message is not a stop condition: continue using tools and send the verified result or blocker afterward.',
|
|
138
|
-
' 2. FINISH, then REPORT. Update the ticket when the actual work calls for a state change. Reply in a supplied human source thread when one exists. For backlog-assigned work, do not call post_message yourself: the watcher publishes your final response
|
|
142
|
+
' 2. FINISH, then REPORT. Update the ticket when the actual work calls for a state change. Reply in a supplied human source thread when one exists. For backlog-assigned work, do not call post_message yourself: the watcher asks you to choose a suitable existing channel and publishes your final response there.',
|
|
139
143
|
' 3. Be honest and specific. Never invent progress. If you are genuinely blocked (missing repo, unclear spec, a failing tool), say exactly what you need in one message — that IS closing the loop.',
|
|
140
144
|
' 4. One final reply per request; answer several nudges together. A single concrete progress update is allowed during longer work, but it must be followed by the final result or blocker in the same cycle.',
|
|
141
145
|
' 5. RECOVER DEAD COMMAND SESSIONS. If write_stdin reports “Unknown process id”, that command session has already exited. Never poll the same process id again. Start a fresh exec_command when more work is required, then continue the task and verify the final state.',
|
|
@@ -155,7 +159,7 @@ const CODE_FULL = [
|
|
|
155
159
|
'Use available tool discovery, resources, history, search, skills, and planning tools when useful. Prefer the repository knowledge graph when available. Recover from optional tool failures using alternatives; report only a limitation that actually prevents the requested outcome.',
|
|
156
160
|
'A usable local clone is your primary code surface. Preserve unrelated work and use an isolated worktree when needed. Remote codebase tools are a fallback only when the repository cannot be obtained locally. For code changes, verify the result and use the authorized branch/PR flow when the request calls for it.',
|
|
157
161
|
'You own ticket state: use update_ticket when its state should change, including review, completion, or a real blocker. Ending your turn does not mark the ticket done. Your native final response ends this cycle; it can report a result, ask a necessary question, or explain a blocker. Do not describe unperformed work as complete.',
|
|
158
|
-
'Follow the supplied source delivery rule. For backlog-only tickets, return your final response; the watcher
|
|
162
|
+
'Follow the supplied source delivery rule. For backlog-only tickets, return your final response; the watcher asks you to select the best-fitting existing channel and delivers the saved report once. Do not call post_message for that same result.',
|
|
159
163
|
].join('\n')
|
|
160
164
|
|
|
161
165
|
const CODE_FAST = [
|
|
@@ -627,7 +631,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
627
631
|
const replyStatusTargets = new Set()
|
|
628
632
|
const activity = dependencies.activityReporter || createActivityReporter({
|
|
629
633
|
backend, apiKey, identifier, log,
|
|
630
|
-
sendFrame: (frame) => handle?.send?.(frame) === true,
|
|
631
634
|
})
|
|
632
635
|
const sendStatus = (channelId, state) => {
|
|
633
636
|
const id = cycleContext.getStore()?.cycleId
|
|
@@ -1009,6 +1012,21 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1009
1012
|
}).finally(() => { mcpToolDiscoveryPromise = null })
|
|
1010
1013
|
return mcpToolDiscoveryPromise
|
|
1011
1014
|
}
|
|
1015
|
+
const conversationReader = createConversationReader({ load: async (scope) => {
|
|
1016
|
+
const names = await discoverMcpTools()
|
|
1017
|
+
const ticket = scope.ticketId != null
|
|
1018
|
+
const tool = ticket ? 'list_task_comment_thread' : 'list_message_thread'
|
|
1019
|
+
if (!names.has(tool)) throw new Error('Conversation thread tool not advertised')
|
|
1020
|
+
const args = ticket ? { project_id: scope.projectId, ticket_id: scope.ticketId, comment_id: scope.threadId }
|
|
1021
|
+
: { channel_id: scope.channelId, message_id: scope.threadId }
|
|
1022
|
+
const [thread, surroundings] = await Promise.all([
|
|
1023
|
+
callMcpTool(tool, args).then(checkedConversationResult),
|
|
1024
|
+
!ticket && names.has('list_channel_messages')
|
|
1025
|
+
? callMcpTool('list_channel_messages', { channel_id: scope.channelId, limit: 12, before_id: scope.threadId }).then(checkedConversationResult).catch(() => null)
|
|
1026
|
+
: null,
|
|
1027
|
+
])
|
|
1028
|
+
return { thread, surroundings }
|
|
1029
|
+
} })
|
|
1012
1030
|
const callOptionalTicketComment = async ({ projectId, ticketId, content, parentId }) => {
|
|
1013
1031
|
let names
|
|
1014
1032
|
try { names = await discoverMcpTools() }
|
|
@@ -1166,7 +1184,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1166
1184
|
save: saveOwnThread,
|
|
1167
1185
|
cancelled: (cid, root) => memory.has(threadControlKey(cid, root), 'cancelled'),
|
|
1168
1186
|
identity: () => ({ id: selfAgentId, identifier, slug, name: slug }),
|
|
1169
|
-
load: async (cid, root) =>
|
|
1187
|
+
load: async (cid, root) => (await conversationReader.read({ channelId: Number(cid), threadId: Number(root) })).thread,
|
|
1170
1188
|
})
|
|
1171
1189
|
const peerTurnKey = (surface, ...parts) => `peer-turn:${surface}:${parts.map((value) => String(value ?? '?')).join(':')}`
|
|
1172
1190
|
const ownTaskCommentKey = (projectId, ticketId, content) => {
|
|
@@ -1174,7 +1192,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1174
1192
|
return `own-task-comment:${projectId}:${ticketId}:${normalized.length}:${normalized.slice(0, 120)}:${normalized.slice(-120)}`
|
|
1175
1193
|
}
|
|
1176
1194
|
const rememberPeerReply = (key, refs = {}) => {
|
|
1177
|
-
if (key) memory.remember({ key, kind: 'peer-turn', state: 'replied', summary: 'Replied
|
|
1195
|
+
if (key) memory.remember({ key, kind: 'peer-turn', state: 'replied', summary: 'Replied to a peer; bounded concrete follow-ups remain available.', refs, meta: memory.get(key)?.meta })
|
|
1178
1196
|
}
|
|
1179
1197
|
const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false, peerLoopKey = '', isCancelled = () => false }) => {
|
|
1180
1198
|
if (stopping) throw new Error('Watcher is stopping')
|
|
@@ -1194,6 +1212,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1194
1212
|
const controlKey = threadControlKey(channelId, parentId)
|
|
1195
1213
|
const suppressCancelled = () => {
|
|
1196
1214
|
if (isCancelled()) return { posted: false, reason: 'cycle-cancelled' }
|
|
1215
|
+
if (sourceMessageId != null && memory.has(`deleted-message:${channelId}:${sourceMessageId}`, 'deleted')) return { posted: false, reason: 'source-deleted' }
|
|
1197
1216
|
if (memory.has(deliveryKey, 'suppressed')) return { posted: false, reason: 'source-cancelled' }
|
|
1198
1217
|
if (allowCancelled || !controlKey || !memory.has(controlKey, 'cancelled')) return null
|
|
1199
1218
|
pendingReplyDeliveries.delete(deliveryKey)
|
|
@@ -1212,7 +1231,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1212
1231
|
}
|
|
1213
1232
|
const run = (async () => {
|
|
1214
1233
|
if (parentId != null) {
|
|
1215
|
-
const live =
|
|
1234
|
+
const live = checkedConversationResult(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
|
|
1216
1235
|
const plans = skipIfAnyAgentReply ? memory.recall({ channelId: Number(channelId), threadId: Number(parentId) }, 1000).filter(node => node.kind === 'work-plan' && node.meta?.sourceKey === sourceKey) : []
|
|
1217
1236
|
const rendered = repliesAfterSource(renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug }), sourceMessageId).filter(row => !plans.some(plan => normalizeRenderedMessageText(plan.summary) === normalizeRenderedMessageText(row.content)))
|
|
1218
1237
|
const duplicate = rendered.some((row) => normalizeRenderedMessageText(row.content) === normalizeRenderedMessageText(message))
|
|
@@ -1364,7 +1383,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1364
1383
|
if (!Number.isFinite(projectId) || !Number.isFinite(ticketId)) return false
|
|
1365
1384
|
const taskKey = `${projectId}:${ticketId}`
|
|
1366
1385
|
if (String(result.outputText || '').trim()) {
|
|
1367
|
-
pendingAgentResults.set(taskKey, { outputText: result.outputText,
|
|
1386
|
+
pendingAgentResults.set(taskKey, { outputText: result.outputText, didResultMessage: !!result.didResultMessage })
|
|
1368
1387
|
pendingCompletionReports.add(taskKey)
|
|
1369
1388
|
trimMap(pendingAgentResults); trimSeen(pendingCompletionReports); persistReplay()
|
|
1370
1389
|
}
|
|
@@ -1397,9 +1416,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1397
1416
|
try {
|
|
1398
1417
|
const comment = await callOptionalTicketComment({ projectId, ticketId, content: report.content })
|
|
1399
1418
|
if (comment.called) log('posted verified ticket comment for #' + ticketId)
|
|
1400
|
-
else log('no ticket comment tool is exposed; completing ticket #' + ticketId + ' through its verified state and
|
|
1419
|
+
else log('no ticket comment tool is exposed; completing ticket #' + ticketId + ' through its verified state and channel report')
|
|
1401
1420
|
} catch (e) {
|
|
1402
|
-
log('optional ticket comment failed for #' + ticketId + ': ' + (e?.message || e) + '; continuing with the verified
|
|
1421
|
+
log('optional ticket comment failed for #' + ticketId + ': ' + (e?.message || e) + '; continuing with the verified channel report')
|
|
1403
1422
|
}
|
|
1404
1423
|
reportedTaskComments.add(report.key); trimSeen(reportedTaskComments); persistReplay()
|
|
1405
1424
|
}
|
|
@@ -1408,19 +1427,52 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1408
1427
|
persistReplay()
|
|
1409
1428
|
return true
|
|
1410
1429
|
}
|
|
1411
|
-
//
|
|
1412
|
-
//
|
|
1413
|
-
|
|
1414
|
-
if (result.didChannelMessage) {
|
|
1430
|
+
// Only a result posted after the work counts as completion delivery. An
|
|
1431
|
+
// earlier acknowledgement/progress message must not suppress the final.
|
|
1432
|
+
if (result.didResultMessage) {
|
|
1415
1433
|
reportedCompletions.add(report.key); trimSeen(reportedCompletions)
|
|
1416
1434
|
pendingCompletionReports.delete(taskKey); pendingAgentResults.delete(taskKey); persistReplay()
|
|
1417
1435
|
log('completion for ticket #' + ticketId + ' was already posted by the work cycle')
|
|
1418
1436
|
return true
|
|
1419
1437
|
}
|
|
1420
|
-
|
|
1421
|
-
|
|
1438
|
+
// The agent chooses the audience from live channels, independently of the
|
|
1439
|
+
// completed coding turn. Save its choice so retries never repeat the work.
|
|
1440
|
+
const channelData = toolData(await callMcpTool('list_channels'), 'list_channels')
|
|
1441
|
+
const channels = (Array.isArray(channelData.channels) ? channelData.channels : []).filter(channel => validBackendId(channel?.id))
|
|
1442
|
+
if (!channels.length) return false
|
|
1443
|
+
let channel = channels.find(row => Number(row.id) === result.channelId)
|
|
1444
|
+
if (!channel) {
|
|
1445
|
+
if (isCancelled() || stopping) return false
|
|
1446
|
+
const routingRunner = makeRunner({ ...runnerOptions, workdir: '', workspaceAvailable: false,
|
|
1447
|
+
systemPrompt: identityContext + '\n\n' + credNote + '\n\n' + REPORT_ROUTING_PROMPT,
|
|
1448
|
+
cfgKey: identifier + '-report-' + slugify(taskKey),
|
|
1449
|
+
})
|
|
1450
|
+
activeReplyRunners.add(routingRunner)
|
|
1451
|
+
try {
|
|
1452
|
+
const names = await discoverMcpTools()
|
|
1453
|
+
const selection = await routingRunner.runCycle(REPORT_ROUTING_PROMPT + '\n\n' + JSON.stringify({
|
|
1454
|
+
task: { projectId, ticketId, title: ticket.title, description: ticket.description },
|
|
1455
|
+
report: report.content,
|
|
1456
|
+
channels: channels.map(row => ({ id: Number(row.id), name: row.name, description: row.description, projectId: row.project_id, agentId: row.agent_id })),
|
|
1457
|
+
}), liteModel, {
|
|
1458
|
+
purpose: 'report-routing', cycleId: `${journal.runId}:report:${report.key}`,
|
|
1459
|
+
contextRefs: { projectId, ticketId },
|
|
1460
|
+
disabledMcpTools: [...names].filter(name => !['list_channels', 'get_channel', 'list_projects', 'get_project', 'get_ticket'].includes(name)),
|
|
1461
|
+
})
|
|
1462
|
+
if (!cycleSucceeded(selection) || isCancelled() || stopping) return false
|
|
1463
|
+
channel = reportChannelSelection(selection.outputText, channels)
|
|
1464
|
+
if (!channel) { log('Agent channel selection was not a live channel; saved report remains pending'); return false }
|
|
1465
|
+
result.channelId = Number(channel.id)
|
|
1466
|
+
persistReplay()
|
|
1467
|
+
emit('delivery.routed', { status: 'selected', channelId: result.channelId, ticket: { projectId, ticketId } })
|
|
1468
|
+
} finally {
|
|
1469
|
+
activeReplyRunners.delete(routingRunner)
|
|
1470
|
+
await routingRunner.close?.()
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
const channelId = Number(channel.id)
|
|
1422
1474
|
if (isCancelled()) return false
|
|
1423
|
-
await postMessageOnce({ key: `completion:${report.key}`,
|
|
1475
|
+
await postMessageOnce({ key: `completion:${report.key}`, channelId, content: report.content, sourceKey: memoryKey })
|
|
1424
1476
|
reportedCompletions.add(report.key); trimSeen(reportedCompletions)
|
|
1425
1477
|
pendingCompletionReports.delete(taskKey); pendingAgentResults.delete(taskKey); persistReplay()
|
|
1426
1478
|
memory.remember({ key: memoryKey, kind: 'ticket', state: 'reported', summary: report.content, refs: { projectId, ticketId, channelId }, meta: { reportKey: report.key, prUrl: report.prUrl } })
|
|
@@ -1532,8 +1584,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1532
1584
|
if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
|
|
1533
1585
|
memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'handoff', summary: task.title, refs: { projectId: project.id, ticketId: task.id } })
|
|
1534
1586
|
if (pendingCompletionReports.has(taskKey)) {
|
|
1535
|
-
|
|
1536
|
-
try { await announceTaskCompletion({ projectId: project.id, ticketId: task.id, channelId: activityChannel }) }
|
|
1587
|
+
try { await announceTaskCompletion({ projectId: project.id, ticketId: task.id }) }
|
|
1537
1588
|
catch (e) { log('completion report retry failed for ticket #' + task.id + ': ' + (e?.message || e)) }
|
|
1538
1589
|
}
|
|
1539
1590
|
if (blockedTasks.delete(taskKey)) { blockedTaskRepos.delete(taskKey); persistReplay() }
|
|
@@ -1678,6 +1729,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1678
1729
|
: activeTaskRef ? { projectId: activeTaskRef.projectId, ticketId: activeTaskRef.ticketId } : {}
|
|
1679
1730
|
let recalled = ''
|
|
1680
1731
|
runnerOptions.contextRefs = memoryRefs
|
|
1732
|
+
if (delivery?.sourceMessage) {
|
|
1733
|
+
let snapshot, unavailable = !!delivery.contextUnavailable
|
|
1734
|
+
try { if (!unavailable) snapshot = await conversationReader.read(memoryRefs) }
|
|
1735
|
+
catch (error) { unavailable = true; log('Compiled conversation history unavailable: ' + (error?.message || error)) }
|
|
1736
|
+
ctx.push(compileConversationContext({ ...snapshot, source: delivery.sourceMessage, scope: memoryRefs, unavailable }))
|
|
1737
|
+
}
|
|
1681
1738
|
try { recalled = await memory.context(memoryRefs) }
|
|
1682
1739
|
catch (error) { log('Optional history unavailable; agent can retrieve context with its tools: ' + (error?.message || error)) }
|
|
1683
1740
|
const capabilityContext = kind !== 'full' && canCode
|
|
@@ -1797,7 +1854,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1797
1854
|
finishedTaskVersions.set(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`, verifiedTaskRevision)
|
|
1798
1855
|
trimMap(finishedTaskVersions); persistReplay()
|
|
1799
1856
|
}
|
|
1800
|
-
if (activeTaskRef) {
|
|
1857
|
+
if (activeTaskRef && !delivery?.watcherOwned) {
|
|
1801
1858
|
try {
|
|
1802
1859
|
const delivered = await announceTaskCompletion(activeTaskRef, completionResult, () => cycleControl.cancelled)
|
|
1803
1860
|
if (!delivered) {
|
|
@@ -1827,6 +1884,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1827
1884
|
// Distill an event's WS payload into a directive the model can act on without
|
|
1828
1885
|
// any extra lookup.
|
|
1829
1886
|
const senderName = (m) => {
|
|
1887
|
+
const attributed = conversationAuthor(m || {})
|
|
1888
|
+
if (attributed.kind === 'agent' && attributed.name !== 'Unknown') return attributed.name
|
|
1830
1889
|
const s = m && (m.taskSender || m.task_sender || m.sender || m.user || m.author || m.member || m.taskSenderAgent || m.task_sender_agent || m.senderAgent || m.sender_agent || m.agent)
|
|
1831
1890
|
if (!s) return ''
|
|
1832
1891
|
return (`${s.first_name || s.firstName || ''} ${s.last_name || s.lastName || ''}`.trim() || s.name || s.display_name || s.identifier || s.email || '')
|
|
@@ -1906,8 +1965,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1906
1965
|
if (taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
|
|
1907
1966
|
memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'handoff', summary: ticket.title, refs: { projectId, ticketId } })
|
|
1908
1967
|
if (pendingCompletionReports.has(key)) {
|
|
1909
|
-
|
|
1910
|
-
try { await announceTaskCompletion({ projectId, ticketId, channelId: activityChannel }) }
|
|
1968
|
+
try { await announceTaskCompletion({ projectId, ticketId }) }
|
|
1911
1969
|
catch (e) { log('completion report failed for ticket #' + ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry') }
|
|
1912
1970
|
}
|
|
1913
1971
|
blockedTasks.delete(key); blockedTaskRepos.delete(key); failedTaskVersions.delete(key); failedOpenCodeProviders.delete(key); persistReplay(); seenTasks.delete(key)
|
|
@@ -1936,7 +1994,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1936
1994
|
if (activityChannel != null) sendStatus(activityChannel, 'thinking')
|
|
1937
1995
|
const humanTicketRef = ticketSlug ? `ticket ${ticketSlug}` : `the ticket “${title}”`
|
|
1938
1996
|
const workspaceDirective = preparedWorktree ? ` A prepared local git worktree for this ticket exists at ${JSON.stringify(preparedWorktree)}. Start repository inspection and edits there. Do not call list_codebases, codebase_tree, or get_codebase while this local worktree is available.` : ''
|
|
1939
|
-
void drain(cycleKind, `Authoritative get_ticket verification confirms ${humanTicketRef} is open and assigned to YOU. Internal tool identity: project_id ${projectId}, ticket_id ${ticketId}. For every get_ticket call pass exactly { project_id: ${projectId}, ticket_id: ${ticketId} }; For list_task_types, follow its advertised schema: resolve the ticket’s board_id from the ticket or list_boards and include it when required. Never guess a board id. Numeric ids are MCP arguments only and must never appear in human-facing text; use ${ticketSlug || 'the ticket title'} instead. Ticket details: ${JSON.stringify({ slug: ticketSlug, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}.${workspaceDirective} This assignment has no source thread: do not call post_message yourself. Choose the tools, context, validation, and ticket state appropriate to this request. Return your final response when you decide this turn is finished; the watcher will
|
|
1997
|
+
void drain(cycleKind, `Authoritative get_ticket verification confirms ${humanTicketRef} is open and assigned to YOU. Internal tool identity: project_id ${projectId}, ticket_id ${ticketId}. For every get_ticket call pass exactly { project_id: ${projectId}, ticket_id: ${ticketId} }; For list_task_types, follow its advertised schema: resolve the ticket’s board_id from the ticket or list_boards and include it when required. Never guess a board id. Numeric ids are MCP arguments only and must never appear in human-facing text; use ${ticketSlug || 'the ticket title'} instead. Ticket details: ${JSON.stringify({ slug: ticketSlug, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}.${workspaceDirective} This assignment has no source thread: do not call post_message yourself. Choose the tools, context, validation, and ticket state appropriate to this request. Return your final response when you decide this turn is finished; the watcher will ask you to select the best-fitting existing channel and publish it there without changing ticket status. A dedicated agent channel is not required.`, activityChannel == null ? [] : [activityChannel], { projectId, ticketId, channelId: activityChannel })
|
|
1940
1998
|
} catch (e) {
|
|
1941
1999
|
log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
|
|
1942
2000
|
}
|
|
@@ -1968,11 +2026,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1968
2026
|
if (messageSenderIsSelf(comment, { id: selfAgentId, identifier, slug, name })) return { status: 'ignored-self' }
|
|
1969
2027
|
const peer = messageSenderIsAgent(comment)
|
|
1970
2028
|
const loopKey = peerTurnKey('task-comment', projectId, ticketId, rootCommentId)
|
|
1971
|
-
if (peer && memory.has(loopKey, 'replied')) {
|
|
1972
|
-
log(`task:comment project=${projectId} ticket=${ticketId} ignored after one peer-agent reply; waiting for a human turn`)
|
|
1973
|
-
return { status: 'ignored-peer-loop' }
|
|
1974
|
-
}
|
|
1975
|
-
if (!peer) memory.remember({ key: loopKey, kind: 'peer-turn', state: 'human', summary: text, refs: { projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(rootCommentId) } })
|
|
1976
2029
|
const target = classifyConversationTarget(comment, [...selfAliases], { threadOwned: true })
|
|
1977
2030
|
if (target.action !== 'handle') {
|
|
1978
2031
|
log('task:comment ignored before model start (' + target.reason + ')')
|
|
@@ -1994,6 +2047,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1994
2047
|
log(`task:comment ticket #${ticketId} is not assigned to this agent — ignored`)
|
|
1995
2048
|
return { status: 'ignored-unassigned' }
|
|
1996
2049
|
}
|
|
2050
|
+
if (peer) {
|
|
2051
|
+
const meta = admitPeerTurn(memory.get(loopKey), text, target)
|
|
2052
|
+
if (!meta) return { status: 'ignored-peer-loop' }
|
|
2053
|
+
memory.remember({ key: loopKey, kind: 'peer-turn', state: 'reserved', meta })
|
|
2054
|
+
} else memory.remember({ key: loopKey, kind: 'peer-turn', state: 'human' })
|
|
2055
|
+
conversationReader.invalidate({ projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(rootCommentId) })
|
|
1997
2056
|
const who = senderName(comment)
|
|
1998
2057
|
const ticketSlug = ticketDisplaySlug(ticket)
|
|
1999
2058
|
memory.remember({ key: sourceKey, kind: 'task-comment', state: 'received', summary: text, refs: { projectId: Number(projectId), ticketId: Number(ticketId), messageId: Number(commentId), threadId: Number(rootCommentId) } })
|
|
@@ -2006,6 +2065,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2006
2065
|
sourceKey,
|
|
2007
2066
|
watcherOwned: true,
|
|
2008
2067
|
peerLoopKey: peer ? loopKey : '',
|
|
2068
|
+
sourceMessage: { ...comment, id: Number(commentId) },
|
|
2009
2069
|
}
|
|
2010
2070
|
const label = ticketSlug || String(ticket.title || 'the assigned ticket')
|
|
2011
2071
|
const context = `The watcher received a new comment on your assigned ticket ${JSON.stringify(label)} from ${JSON.stringify(who || (peer ? 'another agent' : 'a teammate'))}: ${JSON.stringify(text)}. Authoritative ticket details: ${JSON.stringify({ slug: ticketSlug, title: ticket.title, description: ticket.description, priority: ticket.priority, status: ticket.type?.name ?? ticket.task_type?.name ?? ticket.status ?? ticket.state })}. Answer the comment directly in the context of this ticket. Do not call post_message or any task-comment tool; return only the final reply and the watcher will post it once in the source comment thread.${peer ? ' This was authored by another team agent. Address its concrete question or request without replacing it with ticket inventory or adding an open-ended reciprocal question.' : ''}`
|
|
@@ -2026,7 +2086,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2026
2086
|
const messageCandidate = [outer.message, data.message, payload.message].find((item) => item && typeof item === 'object' && !Array.isArray(item))
|
|
2027
2087
|
const directId = source.message_id ?? source.messageId ?? source.id
|
|
2028
2088
|
const directContent = source.content ?? source.body ?? source.text ?? (typeof source.message === 'string' ? source.message : null)
|
|
2029
|
-
|
|
2089
|
+
let message = messageCandidate || (directId != null && directContent != null ? { ...source, content: directContent } : {})
|
|
2090
|
+
const messageId = message.message_id ?? message.messageId ?? message.id
|
|
2091
|
+
// The documented mention envelope may provide the root outside message.
|
|
2092
|
+
// Explicit inner parent fields always win, including malformed ones.
|
|
2093
|
+
if (!['parent_id', 'parentId', 'thread_id', 'threadId', 'reply_to', 'replyTo'].some((key) => message[key] != null)
|
|
2094
|
+
&& validBackendId(source.thread_root_id) && String(source.thread_root_id) !== String(messageId)) message = { ...message, parent_id: Number(source.thread_root_id) }
|
|
2030
2095
|
const channelId = source.channel_id ?? source.channelId ?? source.channel?.id ?? message.channel_id ?? message.channelId ?? message.channel?.id
|
|
2031
2096
|
return { ...source, channel_id: channelId, message }
|
|
2032
2097
|
}
|
|
@@ -2034,6 +2099,31 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2034
2099
|
const mentionIntake = new Map()
|
|
2035
2100
|
function onEvent(k, d) {
|
|
2036
2101
|
if (stopping) return
|
|
2102
|
+
if (['channel:message:updated', 'channel:message:deleted', 'channel:message:reaction'].includes(k)) {
|
|
2103
|
+
const event = conversationEventData(d)
|
|
2104
|
+
if (validBackendId(event.channel_id)) {
|
|
2105
|
+
conversationReader.clearChannel(Number(event.channel_id))
|
|
2106
|
+
const mid = event.message?.id ?? event.message_id
|
|
2107
|
+
if (k === 'channel:message:deleted' && validBackendId(mid)) {
|
|
2108
|
+
memory.remember({ key: `deleted-message:${Number(event.channel_id)}:${Number(mid)}`, kind: 'message-deletion', state: 'deleted' })
|
|
2109
|
+
contextService.forget(`message:${Number(event.channel_id)}:${Number(mid)}`)
|
|
2110
|
+
contextService.forgetThread?.(Number(event.channel_id), Number(mid))
|
|
2111
|
+
cancelThread(Number(event.channel_id), Number(mid), 'Source message deleted')
|
|
2112
|
+
// A reply can itself be the accepted request. Cancel just its work,
|
|
2113
|
+
// without standing down unrelated requests in the parent thread.
|
|
2114
|
+
const fromDeletedSource = (delivery) => Number(delivery?.channelId) === Number(event.channel_id) && Number(delivery?.sourceMessageId) === Number(mid)
|
|
2115
|
+
for (const [key, delivery] of pendingReplyDeliveries) if (fromDeletedSource(delivery)) pendingReplyDeliveries.delete(key)
|
|
2116
|
+
for (const queue of Object.values(queues)) queue.cancel((item) => fromDeletedSource(item.delivery), (item) => {
|
|
2117
|
+
item.control.cancelled = true; item.control.wakeRecovery?.()
|
|
2118
|
+
void item.control.runner?.cancelCurrent?.('source-deleted')
|
|
2119
|
+
})
|
|
2120
|
+
persistReplay()
|
|
2121
|
+
} else if (k === 'channel:message:updated') {
|
|
2122
|
+
try { contextService.observe({ name: 'list_message_thread', args: { channel_id: Number(event.channel_id) }, result: event.message }) } catch { /* live read remains authoritative */ }
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
return
|
|
2126
|
+
}
|
|
2037
2127
|
if (k === 'task:comment') return trackOperation(handleTaskComment(d))
|
|
2038
2128
|
const conversationEvent = k === 'agent:mention' || k === 'agent:message' || k === 'channel:message'
|
|
2039
2129
|
if (!conversationEvent) return trackOperation(routeEvent(k, d))
|
|
@@ -2044,6 +2134,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2044
2134
|
const messageId = message.message_id ?? message.messageId ?? message.id
|
|
2045
2135
|
const malformedParent = parent == null && [message.parent_id, message.parentId, message.thread_id, message.threadId, message.reply_to, message.replyTo].some((value) => value != null)
|
|
2046
2136
|
if (malformedParent || !validBackendId(cid) || !validBackendId(messageId) || (parent != null && !validBackendId(parent))) { log('mention missing valid channel/message identity; ignored'); return }
|
|
2137
|
+
if (memory.has(`deleted-message:${Number(cid)}:${Number(messageId)}`, 'deleted') || parent != null && memory.has(`deleted-message:${Number(cid)}:${Number(parent)}`, 'deleted')) return
|
|
2138
|
+
conversationReader.clearChannel(Number(cid))
|
|
2047
2139
|
const key = `${cid}:${parent ?? message.message_id ?? message.messageId ?? message.id}`
|
|
2048
2140
|
// Keep redirects ordered while a legacy root is being verified. Other
|
|
2049
2141
|
// conversations can resolve and run independently.
|
|
@@ -2063,9 +2155,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2063
2155
|
// Organization fanout includes our own posts. Ignore stable self identity
|
|
2064
2156
|
// before recipient checks, replay guards or thread cancellation run.
|
|
2065
2157
|
if (messageSenderIsSelf(message, { id: selfAgentId, identifier })) return { status: 'ignored-self' }
|
|
2066
|
-
const preliminary = classifyConversationTarget(message, [...selfAliases])
|
|
2158
|
+
const preliminary = classifyConversationTarget(message, [...selfAliases], { threadOwned: parent != null && memory.has(threadControlKey(cid, parent), 'active') })
|
|
2067
2159
|
const irrelevant = preliminary.action === 'ignore' && preliminary.reason !== 'unaddressed-thread-activity'
|
|
2068
2160
|
let rootContent = ''
|
|
2161
|
+
let contextUnavailable = false
|
|
2069
2162
|
if (parent != null && !irrelevant && preliminary.action !== 'stand_down') {
|
|
2070
2163
|
const optional = preliminary.explicitSelf || memory.has(threadControlKey(cid, parent), 'active')
|
|
2071
2164
|
let contextTimer
|
|
@@ -2076,20 +2169,25 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2076
2169
|
// before a model starts. Delivery still re-reads the authoritative thread.
|
|
2077
2170
|
rootContent = optional ? await Promise.race([context, new Promise((resolve) => {
|
|
2078
2171
|
contextTimer = setTimeout(() => {
|
|
2172
|
+
contextUnavailable = true
|
|
2079
2173
|
log(`mention optional context pending channel=${cid} message=${messageId} — continuing with verified recipient`)
|
|
2080
2174
|
resolve('')
|
|
2081
2175
|
}, 1000)
|
|
2082
2176
|
})]) : await context
|
|
2083
2177
|
}
|
|
2084
2178
|
catch (error) {
|
|
2179
|
+
contextUnavailable = true
|
|
2085
2180
|
if (!preliminary.explicitSelf && !memory.has(threadControlKey(cid, parent), 'active')) throw error
|
|
2086
2181
|
log('optional root context unavailable; preserving verified recipient: ' + (error?.message || error))
|
|
2087
2182
|
}
|
|
2088
2183
|
finally { clearTimeout(contextTimer) }
|
|
2089
2184
|
}
|
|
2185
|
+
// Deletion may arrive during identity/history I/O, before there is a
|
|
2186
|
+
// queue entry for cancellation to find. Recheck at the admission boundary.
|
|
2187
|
+
if (memory.has(`deleted-message:${Number(cid)}:${Number(messageId)}`, 'deleted') || parent != null && memory.has(`deleted-message:${Number(cid)}:${Number(parent)}`, 'deleted')) return { status: 'ignored-deleted' }
|
|
2090
2188
|
if (!stopping) {
|
|
2091
2189
|
if (!irrelevant) log(`mention timing intake=${Math.round(performance.now() - intakeStartedAt)}ms source=${k} channel=${cid} message=${messageId}`)
|
|
2092
|
-
return routeEvent('agent:mention', { ...eventData, _sourceEvent: k, _threadRootContent: rootContent })
|
|
2190
|
+
return routeEvent('agent:mention', { ...eventData, _sourceEvent: k, _threadRootContent: rootContent, _contextUnavailable: contextUnavailable })
|
|
2093
2191
|
}
|
|
2094
2192
|
}).catch((error) => { log('mention thread verification failed: ' + (error?.message || error)); return { status: 'retry' } })
|
|
2095
2193
|
.finally(() => { if (mentionIntake.get(key) === next) mentionIntake.delete(key) })
|
|
@@ -2129,11 +2227,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2129
2227
|
log('agent:mention replay recovered for active owned thread')
|
|
2130
2228
|
}
|
|
2131
2229
|
const loopKey = cid != null && threadRoot != null ? peerTurnKey('channel', cid, threadRoot) : ''
|
|
2132
|
-
if (peerMessage && loopKey && memory.has(loopKey, 'replied')) {
|
|
2133
|
-
log('agent:mention ignored before model start (peer-loop-budget)')
|
|
2134
|
-
return { status: 'ignored-peer-loop' }
|
|
2135
|
-
}
|
|
2136
|
-
if (!peerMessage && loopKey) memory.remember({ key: loopKey, kind: 'peer-turn', state: 'human', summary: text, refs: { channelId: Number(cid), threadId: Number(threadRoot) } })
|
|
2137
2230
|
const target = classifyConversationTarget(msg, [...selfAliases], { threadOwned })
|
|
2138
2231
|
if (target.action === 'ignore') {
|
|
2139
2232
|
log('agent:mention ignored before model start (' + target.reason + ')')
|
|
@@ -2154,6 +2247,11 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2154
2247
|
}
|
|
2155
2248
|
return
|
|
2156
2249
|
}
|
|
2250
|
+
if (peerMessage && loopKey) {
|
|
2251
|
+
const meta = admitPeerTurn(memory.get(loopKey), text, target)
|
|
2252
|
+
if (!meta) { log('agent:mention ignored before model start (peer-loop-budget)'); return { status: 'ignored-peer-loop' } }
|
|
2253
|
+
memory.remember({ key: loopKey, kind: 'peer-turn', state: 'reserved', meta })
|
|
2254
|
+
} else if (loopKey) memory.remember({ key: loopKey, kind: 'peer-turn', state: 'human', summary: text, refs: { channelId: Number(cid), threadId: Number(threadRoot) } })
|
|
2157
2255
|
if (cid != null && threadRoot != null) activateThread(cid, threadRoot, text)
|
|
2158
2256
|
memory.remember({ key: sourceKey, kind: 'mention', state: 'received', summary: text, refs: { channelId: cid, threadId: threadRoot, messageId: mid } })
|
|
2159
2257
|
try { contextService.observe({ name: 'list_message_thread', args: { channel_id: cid, message_id: threadRoot }, result: { ...msg, id: mid, content: String(msg.content || msg.body || msg.text || '') } }) }
|
|
@@ -2162,10 +2260,11 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2162
2260
|
// redirect can cancel its process. The watcher delivers the final answer
|
|
2163
2261
|
// for every runtime after an authoritative last-moment thread check.
|
|
2164
2262
|
const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result', allowChannelMessaging = false) => cid != null
|
|
2165
|
-
? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: true, allowChannelMessaging, peerLoopKey: peerMessage ? loopKey : '' }
|
|
2263
|
+
? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, sourceMessage: msg, contextUnavailable: raw._contextUnavailable, watcherOwned: true, allowChannelMessaging, peerLoopKey: peerMessage ? loopKey : '' }
|
|
2166
2264
|
: null
|
|
2167
2265
|
// Under-the-hood model control from chat (view / switch the model the agent runs).
|
|
2168
2266
|
const mcmd = cid != null ? parseModelCmd(text) : null
|
|
2267
|
+
if (peerMessage && mcmd && !mcmd.report) { log('Peer agent cannot change local model configuration'); return }
|
|
2169
2268
|
if (mcmd) {
|
|
2170
2269
|
refreshModelSettings()
|
|
2171
2270
|
const thread = threadRoot != null ? `, parent_id ${threadRoot}` : ''
|
package/studio/app.mjs
CHANGED
|
@@ -15,7 +15,7 @@ export function normalizeStatus(value, fallback = 'observed') {
|
|
|
15
15
|
if (['active', 'running', 'started', 'ready', 'in_progress', 'working', 'online', 'preflight'].includes(status)) return 'active';
|
|
16
16
|
if (['queued', 'pending', 'waiting'].includes(status)) return 'queued';
|
|
17
17
|
if (['completed', 'complete', 'finished', 'done', 'success', 'succeeded', 'ok'].includes(status)) return 'completed';
|
|
18
|
-
if (['failed', 'failure', 'error', 'rejected', 'rate_limited'].includes(status)) return 'failed';
|
|
18
|
+
if (['failed', 'failure', 'error', 'rejected', 'rate_limited', 'handoff_error'].includes(status)) return 'failed';
|
|
19
19
|
if (['cancelled', 'canceled', 'aborted', 'interrupted'].includes(status)) return 'cancelled';
|
|
20
20
|
if (['offline', 'stopped', 'disconnected', 'exited'].includes(status)) return 'offline';
|
|
21
21
|
if (['skipped', 'recovering', 'idle', 'blocked', 'timeout', 'continued'].includes(status)) return status;
|
|
@@ -115,7 +115,7 @@ export function deriveModel(input) {
|
|
|
115
115
|
const prior = runtimes.get(processKey) || {};
|
|
116
116
|
runtimes.set(processKey, {
|
|
117
117
|
...prior, ...data, id: processKey, type: 'process', agent: event.agent, event,
|
|
118
|
-
name:
|
|
118
|
+
name: runtimeLabel({ ...prior, ...data }),
|
|
119
119
|
pid: Number.isInteger(data.runtimePid) && data.runtimePid > 0 ? data.runtimePid : prior.pid ?? null,
|
|
120
120
|
status: eventStatus(event), lastSeen: event.timestamp,
|
|
121
121
|
});
|
|
@@ -181,15 +181,22 @@ export function taskActivityCycles(model, selectedCycle) {
|
|
|
181
181
|
return [...latest.values()].sort((a, b) => agentId(a.agent).localeCompare(agentId(b.agent)));
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
export function runtimeLabel(data = {}) {
|
|
185
|
+
return data.lane === 'reply' ? 'Reply runtime' : data.lane === 'work' ? 'Coding runtime' : 'Agent runtime';
|
|
186
|
+
}
|
|
187
|
+
|
|
184
188
|
export function eventTitle(event) {
|
|
185
189
|
const data = record(event?.data);
|
|
190
|
+
if (['process.started', 'process.ready', 'process.stopped'].includes(event?.type)) {
|
|
191
|
+
const phase = event.type === 'process.started' ? 'starting' : event.type === 'process.stopped' ? 'stopped' : data.status === 'idle' ? 'idle' : 'ready';
|
|
192
|
+
return `${runtimeLabel(data)} ${phase}`;
|
|
193
|
+
}
|
|
186
194
|
const titles = {
|
|
187
195
|
'runtime.started': 'Watcher connected', 'runtime.heartbeat': 'Watcher heartbeat', 'runtime.stopped': 'Watcher stopped',
|
|
188
196
|
'cycle.queued': 'Cycle queued', 'cycle.started': 'Cycle started', 'cycle.finished': 'Cycle finished',
|
|
189
|
-
'cycle.continued': '
|
|
197
|
+
'cycle.continued': 'Coding continuation queued',
|
|
190
198
|
'cycle.completed': 'Cycle completed', 'cycle.cancelled': 'Cycle cancelled', 'cycle.failed': 'Cycle failed',
|
|
191
199
|
'plan.updated': 'Plan updated', 'output.progress': 'Progress update', 'output.final': 'Final response',
|
|
192
|
-
'process.started': 'Coding runtime starting', 'process.ready': 'Coding runtime ready', 'process.stopped': 'Coding runtime stopped',
|
|
193
200
|
};
|
|
194
201
|
if (text(event?.type).startsWith('tool.')) {
|
|
195
202
|
const action = event.type === 'tool.started' ? 'Started' : ['tool.finished', 'tool.completed'].includes(event.type) ? 'Finished' : 'Updated';
|
|
@@ -767,7 +774,7 @@ function initializeStudio() {
|
|
|
767
774
|
inspector.replaceChildren(inspectorHeader('Process details', process.name, agentName(process.agent), process.status));
|
|
768
775
|
const section = detailSection('Process record', 'terminal');
|
|
769
776
|
section.append(detailGrid([
|
|
770
|
-
['Kind', process.type === 'watcher' ? 'BYO watcher' :
|
|
777
|
+
['Kind', process.type === 'watcher' ? 'BYO watcher' : runtimeLabel(process)], ['Provider', process.agent.provider || process.provider],
|
|
771
778
|
['PID', process.pid || (process.status === 'demo' ? 'Simulated' : 'Not reported'), true],
|
|
772
779
|
['Last event', fullTime(process.lastSeen)], ['Session', process.sessionId, true], ['Run ID', process.runId || process.event?.runId, true],
|
|
773
780
|
]));
|
|
@@ -802,6 +809,7 @@ function initializeStudio() {
|
|
|
802
809
|
return;
|
|
803
810
|
}
|
|
804
811
|
$('demo-badge').hidden = !state.snapshot.demo;
|
|
812
|
+
$('open-agent-connect').disabled = state.snapshot.demo;
|
|
805
813
|
for (const button of document.querySelectorAll('[data-view]')) {
|
|
806
814
|
const selected = button.dataset.view === state.view; button.classList.toggle('is-selected', selected); button.setAttribute('aria-pressed', String(selected));
|
|
807
815
|
}
|
|
@@ -813,6 +821,55 @@ function initializeStudio() {
|
|
|
813
821
|
}
|
|
814
822
|
|
|
815
823
|
$('server-address').textContent = location.host;
|
|
824
|
+
let connectingAgent = false;
|
|
825
|
+
const credentialFields = ['name', 'identifier', 'apiKey', 'backend', 'mcpUrl', 'wsUrl', 'agent'];
|
|
826
|
+
$('open-agent-connect').addEventListener('click', () => {
|
|
827
|
+
$('agent-connect-error').hidden = true;
|
|
828
|
+
$('agent-connect-dialog').showModal();
|
|
829
|
+
});
|
|
830
|
+
$('close-agent-connect').addEventListener('click', () => { if (!connectingAgent) $('agent-connect-dialog').close(); });
|
|
831
|
+
$('agent-connect-dialog').addEventListener('cancel', event => { if (connectingAgent) event.preventDefault(); });
|
|
832
|
+
$('agent-connect-dialog').addEventListener('close', () => $('agent-connect-form').reset());
|
|
833
|
+
$('connect-credentials').addEventListener('input', () => {
|
|
834
|
+
if (!$('connect-credentials').value.trim()) return;
|
|
835
|
+
try {
|
|
836
|
+
const credentials = JSON.parse($('connect-credentials').value);
|
|
837
|
+
if (!credentials || typeof credentials !== 'object' || Array.isArray(credentials)) throw new Error();
|
|
838
|
+
for (const key of credentialFields) $('connect-' + key).value = typeof credentials[key] === 'string' ? credentials[key] : key === 'agent' ? 'claude' : '';
|
|
839
|
+
$('connect-credentials').value = '';
|
|
840
|
+
$('agent-connect-error').hidden = true;
|
|
841
|
+
} catch {
|
|
842
|
+
$('agent-connect-error').textContent = 'Paste the complete credentials from Copy credentials in OpenVisio, or enter the fields below.';
|
|
843
|
+
$('agent-connect-error').hidden = false;
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
$('agent-connect-form').addEventListener('submit', async event => {
|
|
847
|
+
event.preventDefault();
|
|
848
|
+
if (connectingAgent) return;
|
|
849
|
+
connectingAgent = true;
|
|
850
|
+
$('save-agent-connect').disabled = true;
|
|
851
|
+
$('close-agent-connect').disabled = true;
|
|
852
|
+
$('save-agent-connect').textContent = 'Verifying…';
|
|
853
|
+
$('agent-connect-error').hidden = true;
|
|
854
|
+
try {
|
|
855
|
+
const input = Object.fromEntries(credentialFields.map(key => [key, $('connect-' + key).value.trim()]));
|
|
856
|
+
const result = await settingsFetch('/api/agents/connect', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input) });
|
|
857
|
+
$('agent-connect-dialog').close();
|
|
858
|
+
state.selectedAgent = result.identifier;
|
|
859
|
+
state.agentSearch = ''; $('agent-search').value = '';
|
|
860
|
+
toast('Agent connected. Choose Start watcher when your runtime is installed and signed in.');
|
|
861
|
+
receive(await settingsFetch('/api/snapshot'), true);
|
|
862
|
+
} catch (error) {
|
|
863
|
+
$('agent-connect-error').hidden = false;
|
|
864
|
+
$('agent-connect-error').textContent = error.message;
|
|
865
|
+
} finally {
|
|
866
|
+
connectingAgent = false;
|
|
867
|
+
$('save-agent-connect').disabled = false;
|
|
868
|
+
$('close-agent-connect').disabled = false;
|
|
869
|
+
$('save-agent-connect').textContent = 'Verify and connect';
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
|
|
816
873
|
$('open-model-settings').addEventListener('click', () => {
|
|
817
874
|
const agent = state.model.agents.find(agent => agentId(agent) === state.selectedAgent);
|
|
818
875
|
if (agent?.settingsEditable) void openModelSettings(agent);
|
package/studio/guide.html
CHANGED
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
<h2>Show your agents</h2>
|
|
59
59
|
<p>A <strong>watcher</strong> is the local program that listens for assignments and messages, then starts your coding agent when work arrives. Studio displays what the watcher records.</p>
|
|
60
60
|
<ol>
|
|
61
|
-
<li>
|
|
61
|
+
<li>After registering your agent in OpenVisio, choose <strong>Copy credentials</strong>. In Studio, choose <strong>Connect agent</strong>, paste the credentials, and choose <strong>Verify and connect</strong>. You can also enter the fields individually or use the optional terminal command. If you already connected it, keep that setup.</li>
|
|
62
62
|
<li>Select the agent in Studio and choose <strong>Start watcher</strong>. Studio installs its background watcher and starts it with the current package and saved model settings.</li>
|
|
63
63
|
<li>Return to Studio. Your agent should appear in the sidebar. Select its name to see its activity.</li>
|
|
64
64
|
</ol>
|
package/studio/index.html
CHANGED
|
@@ -60,13 +60,31 @@
|
|
|
60
60
|
<main id="workspace" class="workspace" tabindex="-1">
|
|
61
61
|
<header class="page-header">
|
|
62
62
|
<div><div class="eyebrow">Runtime overview <span id="demo-badge" class="badge demo-badge" hidden>Demo data</span></div><h1>Agent studio</h1><p class="page-description">See what your agents are doing, as it happens.</p></div>
|
|
63
|
-
<div class="header-actions"><a class="button header-documentation" href="/guide"><svg class="icon" aria-hidden="true"><use href="#i-book"/></svg>Documentation</a><span id="connection-status" class="connection-status" role="status"><span class="dot"></span><span>Connecting</span></span><button id="pause-button" class="button" type="button" aria-pressed="false"><svg class="icon" aria-hidden="true"><use href="#i-pause"/></svg><span>Pause view</span></button></div>
|
|
63
|
+
<div class="header-actions"><button id="open-agent-connect" class="button primary" type="button" disabled>Connect agent</button><a class="button header-documentation" href="/guide"><svg class="icon" aria-hidden="true"><use href="#i-book"/></svg>Documentation</a><span id="connection-status" class="connection-status" role="status"><span class="dot"></span><span>Connecting</span></span><button id="pause-button" class="button" type="button" aria-pressed="false"><svg class="icon" aria-hidden="true"><use href="#i-pause"/></svg><span>Pause view</span></button></div>
|
|
64
64
|
</header>
|
|
65
65
|
<div id="notice" class="notice" role="status" hidden></div>
|
|
66
66
|
<section id="model-settings-summary" class="model-settings-summary" aria-label="Agent model settings" hidden>
|
|
67
67
|
<div><h2 id="model-settings-agent">Model settings</h2><p id="model-settings-state" role="status"></p></div>
|
|
68
68
|
<div class="header-actions model-settings-actions-row"><button id="toggle-watcher" class="button studio-action" type="button"><svg class="icon" aria-hidden="true"><use href="#i-power"/></svg><span>Start watcher</span></button><button id="restart-watcher" class="button studio-action restart-action" type="button"><svg class="icon" aria-hidden="true"><use href="#i-refresh"/></svg><span>Restart watcher</span></button><button id="open-model-settings" class="button studio-action settings-action" type="button"><svg class="icon" aria-hidden="true"><use href="#i-sliders"/></svg><span>Model settings</span></button><button id="open-agent-removal" class="button studio-action remove-action" type="button"><svg class="icon" aria-hidden="true"><use href="#i-user-minus"/></svg><span>Remove agent</span></button></div>
|
|
69
69
|
</section>
|
|
70
|
+
<dialog id="agent-connect-dialog" aria-labelledby="agent-connect-title">
|
|
71
|
+
<form id="agent-connect-form" class="model-settings-form" autocomplete="off">
|
|
72
|
+
<div class="section-heading"><h2 id="agent-connect-title">Connect agent</h2><button id="close-agent-connect" class="button" type="button">Cancel</button></div>
|
|
73
|
+
<p>Copy credentials from OpenVisio after registering your agent, then paste them below. Or enter the fields yourself.</p>
|
|
74
|
+
<label for="connect-credentials">Paste credentials</label>
|
|
75
|
+
<textarea id="connect-credentials" rows="3" spellcheck="false" placeholder="Paste the copied credentials here"></textarea>
|
|
76
|
+
<label for="connect-name">Local agent name</label><input id="connect-name" type="text" required maxlength="4096" spellcheck="false">
|
|
77
|
+
<label for="connect-identifier">Agent identifier</label><input id="connect-identifier" type="text" required maxlength="4096" spellcheck="false">
|
|
78
|
+
<label for="connect-apiKey">API key</label><input id="connect-apiKey" type="password" required maxlength="4096" spellcheck="false">
|
|
79
|
+
<label for="connect-backend">Backend URL</label><input id="connect-backend" type="url" required maxlength="4096" spellcheck="false">
|
|
80
|
+
<label for="connect-mcpUrl">MCP URL</label><input id="connect-mcpUrl" type="url" required maxlength="4096" spellcheck="false">
|
|
81
|
+
<label for="connect-wsUrl">WebSocket URL</label><input id="connect-wsUrl" type="url" required maxlength="4096" spellcheck="false">
|
|
82
|
+
<label for="connect-agent">Runtime</label><select id="connect-agent"><option value="claude">Claude Code</option><option value="codex">Codex</option><option value="opencode">OpenCode</option><option value="gemini">Gemini CLI</option><option value="qwen">Qwen Code</option></select>
|
|
83
|
+
<p>Install and sign in to your chosen runtime on this computer. After connecting, select the agent and choose Start watcher.</p>
|
|
84
|
+
<p id="agent-connect-error" role="alert" hidden></p>
|
|
85
|
+
<div class="model-settings-actions"><button id="save-agent-connect" class="button primary" type="submit">Verify and connect</button></div>
|
|
86
|
+
</form>
|
|
87
|
+
</dialog>
|
|
70
88
|
<dialog id="model-settings-dialog" aria-labelledby="model-settings-title">
|
|
71
89
|
<form id="model-settings-form" class="model-settings-form">
|
|
72
90
|
<div class="section-heading"><h2 id="model-settings-title">Model settings</h2><button id="close-model-settings" class="button" type="button" aria-label="Close model settings">Close</button></div>
|