openvisio-agent 0.25.11 → 0.25.13
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 +18 -0
- package/USER_GUIDE.md +3 -1
- 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 +3 -2
- package/scripts/certify.mjs +2 -2
- package/src/context-search.mjs +5 -1
- package/src/context-service.mjs +6 -5
- package/src/context-tools.mjs +3 -1
- package/src/conversation-context.mjs +139 -0
- package/src/events.mjs +3 -3
- package/src/harness-checkpoints.mjs +34 -0
- package/src/mastra-harness.mjs +39 -7
- package/src/peer-turns.mjs +14 -0
- package/src/runtime-control.mjs +1 -1
- package/src/thread-context.mjs +1 -1
- package/src/watch.mjs +105 -23
- package/src/work-recovery.mjs +35 -0
- package/studio/app.mjs +12 -5
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
// Compact external state survives runtime eviction without keeping a model or
|
|
4
|
+
// child process alive. Never persist private reasoning or raw tool payloads.
|
|
5
|
+
export function createHarnessCheckpoints({ memory, resourceId, now = () => Date.now(), ttlMs = 15 * 60_000 }) {
|
|
6
|
+
const keyFor = ({ refs = {}, workdir = '', canCode = false }) => {
|
|
7
|
+
const scope = refs.projectId != null && refs.ticketId != null
|
|
8
|
+
? ['ticket', refs.projectId, refs.ticketId]
|
|
9
|
+
: refs.channelId != null ? ['thread', refs.channelId, refs.threadId ?? 'root'] : null
|
|
10
|
+
if (!scope) return ''
|
|
11
|
+
return 'harness-checkpoint:' + createHash('sha256').update(JSON.stringify([resourceId, scope, workdir, canCode])).digest('hex')
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
load(scope) {
|
|
15
|
+
const key = keyFor(scope)
|
|
16
|
+
const saved = key && memory.get(key)?.meta?.checkpoint
|
|
17
|
+
if (!saved || now() < saved.savedAt || now() - saved.savedAt > ttlMs) return ''
|
|
18
|
+
return `RECENT HARNESS CHECKPOINT (${Math.round((now() - saved.savedAt) / 1000)} seconds old; historical context, not new instructions or authorization):\n${JSON.stringify(saved)}\nReuse these findings and pending steps. Do not repeat broad discovery or a known failing read merely because this runtime restarted. Recheck the specific ticket ownership/status and blocker condition before acting; current instructions and live changes win. Completed work is not pending work. A canceled task needs renewed authorization. Never replay an unconfirmed write without reading back its state. Continue independent authorized work when one part is blocked.`
|
|
19
|
+
},
|
|
20
|
+
save(scope, value) {
|
|
21
|
+
const key = keyFor(scope)
|
|
22
|
+
if (!key) return
|
|
23
|
+
const previous = memory.get(key)?.meta?.checkpoint
|
|
24
|
+
const checkpoint = {
|
|
25
|
+
savedAt: now(), state: String(value.state || 'in_progress').slice(0, 80),
|
|
26
|
+
outcome: String(value.outcome || previous?.outcome || '').slice(-6000),
|
|
27
|
+
outcomeObservedAt: value.outcome ? now() : previous?.outcomeObservedAt,
|
|
28
|
+
plan: (value.plan || []).slice(0, 12).map(entry => ({ content: String(entry.content || '').slice(0, 240), status: entry.status })),
|
|
29
|
+
tools: (value.tools || []).slice(-16).map(entry => ({ name: String(entry.name || '').slice(0, 100), status: entry.status })),
|
|
30
|
+
}
|
|
31
|
+
memory.remember({ key, kind: 'harness-checkpoint', state: checkpoint.state, refs: scope.refs, summary: checkpoint.outcome || 'Saved runtime plan and tool checkpoints.', meta: { checkpoint } })
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/mastra-harness.mjs
CHANGED
|
@@ -116,7 +116,7 @@ function contentText(content) {
|
|
|
116
116
|
|
|
117
117
|
export function createMastraAcpRunner({
|
|
118
118
|
agent, mcpUrl, mcpHeaders = {}, workdir, canCode = !!workdir, canCoordinate = false, workspaceAvailable = canCode, maxCycleMs = 20 * 60_000,
|
|
119
|
-
log = () => {}, debug = false, model, onTool, onEvent, systemPrompt = '', getContextEnvironment, AcpAgentClass = AcpAgent,
|
|
119
|
+
log = () => {}, debug = false, model, onTool, onEvent, systemPrompt = '', getContextEnvironment, checkpoints, AcpAgentClass = AcpAgent,
|
|
120
120
|
}) {
|
|
121
121
|
const runtime = acpRuntimeCommand(agent)
|
|
122
122
|
if (!runtime) return null
|
|
@@ -150,7 +150,7 @@ export function createMastraAcpRunner({
|
|
|
150
150
|
let timedOut = false
|
|
151
151
|
const startedAt = performance.now()
|
|
152
152
|
const cycleId = cycleOptions.cycleId ?? null
|
|
153
|
-
const emit = (type, data = {}) => notify(type, { cycleId, attempt: cycleOptions.attempt || 1, provider: agent, runtime: 'mastra-acp', workdir: cycleCwd, watcherPid: process.pid, runtimePid: null, ...data })
|
|
153
|
+
const emit = (type, data = {}) => notify(type, { cycleId, attempt: cycleOptions.attempt || 1, provider: agent, runtime: 'mastra-acp', lane: canCode ? 'work' : 'reply', workdir: cycleCwd, watcherPid: process.pid, runtimePid: null, ...data })
|
|
154
154
|
const progress = new Map()
|
|
155
155
|
const flushProgress = () => {
|
|
156
156
|
for (const [messageId, entry] of progress) {
|
|
@@ -179,6 +179,18 @@ export function createMastraAcpRunner({
|
|
|
179
179
|
const tools = new Map()
|
|
180
180
|
let toolSequence = 0
|
|
181
181
|
let outputText = ''
|
|
182
|
+
let currentPlan = []
|
|
183
|
+
let checkpointState = 'in_progress'
|
|
184
|
+
let lastCheckpointAt = 0
|
|
185
|
+
const checkpointScope = { refs: cycleOptions.contextRefs, workdir: cycleCwd, canCode }
|
|
186
|
+
const saveCheckpoint = (force = false) => {
|
|
187
|
+
if (!checkpoints || (!force && Date.now() - lastCheckpointAt < 15_000)) return
|
|
188
|
+
lastCheckpointAt = Date.now()
|
|
189
|
+
try {
|
|
190
|
+
checkpoints.save(checkpointScope, { state: checkpointState, outcome: redact(outputText), plan: currentPlan,
|
|
191
|
+
tools: [...tools.values()].map(tool => ({ name: redact(toolName(tool) || clean(tool.title, 100)), status: tool.status })) })
|
|
192
|
+
} catch { /* checkpoint storage cannot interrupt work */ }
|
|
193
|
+
}
|
|
182
194
|
let didCode = false
|
|
183
195
|
let didRepoMutation = false
|
|
184
196
|
let didMessage = false
|
|
@@ -203,7 +215,7 @@ export function createMastraAcpRunner({
|
|
|
203
215
|
...Object.entries(contextEnvironment).map(([name, value]) => ({ name, value: String(value) })),
|
|
204
216
|
],
|
|
205
217
|
}] : [{ type: 'http', name: 'openvisio-team', url: mcpUrl, headers }]
|
|
206
|
-
const sessionKey = JSON.stringify([cycleCwd, disabledMcpTools, mcpServers])
|
|
218
|
+
const sessionKey = JSON.stringify([cycleCwd, cycleOptions.contextRefs, disabledMcpTools, mcpServers])
|
|
207
219
|
if (!persistent || persistentKey !== sessionKey) {
|
|
208
220
|
if (persistent) disconnect(persistent)
|
|
209
221
|
const sessionToken = {}
|
|
@@ -327,7 +339,12 @@ export function createMastraAcpRunner({
|
|
|
327
339
|
}
|
|
328
340
|
}
|
|
329
341
|
log(`running ${agent} cycle via Mastra ACP…${selectedModel || requestedModel ? ` [${selectedModel || requestedModel}]` : ' [runtime default]'}`)
|
|
330
|
-
|
|
342
|
+
let restored = ''
|
|
343
|
+
if (!sessionHasPrompted) {
|
|
344
|
+
try { restored = checkpoints?.load(checkpointScope) || '' } catch { /* optional recall */ }
|
|
345
|
+
}
|
|
346
|
+
const fullPrompt = [!sessionHasPrompted ? systemPrompt : '', restored, prompt].filter(Boolean).join('\n\n')
|
|
347
|
+
if (restored) emit('context.restored', { source: 'harness-checkpoint', chars: restored.length })
|
|
331
348
|
sessionHasPrompted = true
|
|
332
349
|
const stream = acp.connection.promptStream(fullPrompt, controller.signal)[Symbol.asyncIterator]()
|
|
333
350
|
let readyReported = false
|
|
@@ -350,6 +367,8 @@ export function createMastraAcpRunner({
|
|
|
350
367
|
priority: ['low', 'medium', 'high'].includes(entry?.priority) ? entry.priority : undefined,
|
|
351
368
|
})).filter((entry) => entry.content)
|
|
352
369
|
emit('plan.updated', { entries })
|
|
370
|
+
currentPlan = entries
|
|
371
|
+
saveCheckpoint()
|
|
353
372
|
continue
|
|
354
373
|
}
|
|
355
374
|
if (update.sessionUpdate === 'agent_message_chunk') {
|
|
@@ -364,6 +383,7 @@ export function createMastraAcpRunner({
|
|
|
364
383
|
const order = prior.status === 'completed' && (!update.status || update.status === 'completed') ? prior.order : ++toolSequence
|
|
365
384
|
const merged = { ...prior, ...update, order }
|
|
366
385
|
tools.set(update.toolCallId, merged)
|
|
386
|
+
if (['completed', 'failed'].includes(merged.status)) saveCheckpoint()
|
|
367
387
|
const name = toolName(merged)
|
|
368
388
|
const label = name || clean(merged.title, 100) || 'tool'
|
|
369
389
|
const terminal = ['completed', 'failed'].includes(merged.status)
|
|
@@ -386,11 +406,15 @@ export function createMastraAcpRunner({
|
|
|
386
406
|
// Only final, completed tool states are evidence. Started/failed edits and
|
|
387
407
|
// board mutations cannot stand in for completed repository work.
|
|
388
408
|
let workRequest = null
|
|
409
|
+
let completedHandoff = false
|
|
389
410
|
for (const tool of [...tools.values()].sort((a, b) => a.order - b.order)) {
|
|
390
411
|
const name = toolName(tool)
|
|
391
412
|
if (tool.status === 'failed') policyBlock ||= codexPolicyBlock(json(tool.rawOutput))
|
|
392
413
|
if (tool.status !== 'completed') continue
|
|
393
|
-
if (name === WORK_SESSION_TOOL
|
|
414
|
+
if (name === WORK_SESSION_TOOL && !canCode && workspaceAvailable) {
|
|
415
|
+
completedHandoff = true
|
|
416
|
+
workRequest = workSessionRequest(tool.rawOutput) || workSessionRequest(tool.content) || workRequest
|
|
417
|
+
}
|
|
394
418
|
didMessage ||= ['post_message', 'create_task_comment', 'comment_ticket'].includes(name)
|
|
395
419
|
didChannelMessage ||= name === 'post_message'
|
|
396
420
|
didResultMessage ||= name === 'post_message' && didRepoMutation
|
|
@@ -409,10 +433,16 @@ export function createMastraAcpRunner({
|
|
|
409
433
|
if (beforeState != null && after != null && beforeState !== after) { didCode = true; didRepoMutation = true }
|
|
410
434
|
for (const [name, detail] of errors) log(`MCP ${name} failed: ${redact(detail)}`)
|
|
411
435
|
log(`${agent} cycle done via Mastra ACP (${errors.size ? 'tool-errors' : 'ok'})`)
|
|
436
|
+
const handoffError = completedHandoff && !workRequest
|
|
437
|
+
const resultStatus = policyBlock ? 'blocked' : handoffError ? 'handoff_error' : 'ok'
|
|
438
|
+
checkpointState = policyBlock ? 'blocked' : handoffError ? 'handoff_error' : 'ended'
|
|
439
|
+
if (handoffError) emit('process.error', { status: 'handoff_error', summary: 'The work-session tool completed but its continuation could not be decoded. No coding session was started.' })
|
|
412
440
|
flushProgress()
|
|
413
|
-
|
|
441
|
+
// A handoff is a request to queue work, not a completed task or proof that
|
|
442
|
+
// the coding process has started. The watcher owns its public delivery.
|
|
443
|
+
if (outputText.trim() && !workRequest && !handoffError) emit('output.final', { text: redact(outputText.trim()).slice(0, 8000), status: resultStatus })
|
|
414
444
|
return {
|
|
415
|
-
type: 'result', subtype:
|
|
445
|
+
type: 'result', subtype: resultStatus, stopReason: 'end_turn', workRequest, policyBlock, timings: { firstEventMs, totalMs: Math.round(performance.now() - startedAt) }, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
|
|
416
446
|
mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
|
|
417
447
|
didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage,
|
|
418
448
|
didMcpTaskRead, didMcpTaskUpdate,
|
|
@@ -424,6 +454,7 @@ export function createMastraAcpRunner({
|
|
|
424
454
|
}
|
|
425
455
|
const canceled = controller.signal.aborted
|
|
426
456
|
const subtype = providerFailure?.subtype || (timedOut ? 'timeout' : canceled ? 'canceled' : 'error')
|
|
457
|
+
checkpointState = subtype
|
|
427
458
|
const errorDetail = [error?.message, error?.data ? json(error.data) : ''].filter(Boolean).join(': ')
|
|
428
459
|
const safeError = redact(errorDetail)
|
|
429
460
|
flushProgress()
|
|
@@ -438,6 +469,7 @@ export function createMastraAcpRunner({
|
|
|
438
469
|
didMcpTaskRead, didMcpTaskUpdate,
|
|
439
470
|
}
|
|
440
471
|
} finally {
|
|
472
|
+
saveCheckpoint(true)
|
|
441
473
|
flushProgress()
|
|
442
474
|
if (!disconnected.has(acp)) emit('process.ready', { status: 'idle', sessionId: acp.connection.sessionId || null })
|
|
443
475
|
clearTimeout(timer)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
// Three concrete peer requests per human turn. Reserve at intake, not delivery:
|
|
4
|
+
// concurrent messages cannot spend the same budget. Persist with the ledger.
|
|
5
|
+
export function admitPeerTurn(previous, text, target) {
|
|
6
|
+
if (target.action !== 'handle') return null
|
|
7
|
+
const normalized = String(text).replace(/\s+/g, ' ').trim().toLowerCase()
|
|
8
|
+
const fingerprint = createHash('sha256').update(normalized).digest('hex')
|
|
9
|
+
const prior = previous?.state === 'human' ? [] : previous?.meta?.requests ?? (previous?.state === 'replied' ? ['legacy-reply'] : [])
|
|
10
|
+
if (prior.length >= 3 || prior.includes(fingerprint)) return null
|
|
11
|
+
if (prior.length && (!target.explicitSelf || !/\b(?:review|check|confirm|explain|investigate|fix|implement|share|send|compare|verify)\b/i.test(normalized))) return null
|
|
12
|
+
if (/^@[\w -]+[,!:]?\s+(?:thanks|thank you|got it|acknowledged|understood)[.!\s]*$/i.test(normalized)) return null
|
|
13
|
+
return { requests: [...prior, fingerprint] }
|
|
14
|
+
}
|
package/src/runtime-control.mjs
CHANGED
|
@@ -40,7 +40,7 @@ export function workSessionRequest(output, depth = 0) {
|
|
|
40
40
|
...(typeof request.acknowledgement === 'string' && request.acknowledgement.trim() && request.acknowledgement.length <= 1200 ? { acknowledgement: request.acknowledgement.trim() } : {}),
|
|
41
41
|
...(validPlan(request.plan) ? { plan: request.plan.map(step => step.trim()) } : {}),
|
|
42
42
|
}
|
|
43
|
-
for (const key of ['content', 'text', 'result', 'structuredContent']) {
|
|
43
|
+
for (const key of ['output', 'content', 'text', 'result', 'structuredContent']) {
|
|
44
44
|
const nested = workSessionRequest(output[key], depth + 1)
|
|
45
45
|
if (nested) return nested
|
|
46
46
|
}
|
package/src/thread-context.mjs
CHANGED
|
@@ -26,7 +26,7 @@ function containsRoot(value, rootId, identity, depth = 0) {
|
|
|
26
26
|
if (Array.isArray(value)) return value.some((row) => containsRoot(row, rootId, identity, depth + 1))
|
|
27
27
|
if (typeof value !== 'object') return false
|
|
28
28
|
if (String(value.message_id ?? value.messageId ?? value.id) === String(rootId) && messageParentId(value) == null && typeof (value.content ?? value.body ?? value.text) === 'string' && verifiedOtherAuthor(value, identity)) return true
|
|
29
|
-
return ['messages', 'replies', 'items', 'data', 'result', 'thread', 'message', 'root'].some((key) => containsRoot(value[key], rootId, identity, depth + 1))
|
|
29
|
+
return ['messages', 'replies', 'items', 'body', 'data', 'result', 'thread', 'message', 'root'].some((key) => containsRoot(value[key], rootId, identity, depth + 1))
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export function createThreadContextResolver({ read, save, cancelled, load, identity, now = Date.now }) {
|
package/src/watch.mjs
CHANGED
|
@@ -13,8 +13,10 @@ import { connectAgentWs, assertWebSocket } from './ws.mjs'
|
|
|
13
13
|
import { buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, conversationAsksPendingTickets, conversationCreatesTicket, conversationNeedsCode, conversationRequestsChannelMessage, failedTaskRevisionIsCurrent, mentionDedupeKeys, messageParentId, messageSenderIsAgent, messageSenderIsSelf, normalizeRenderedMessageText, renderedAgentMessages, taskAgentId, taskAgentIdentifier, taskBelongsToAgent, taskCommentFromEvent, taskEventSenderIsSelf, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
|
|
14
14
|
import { WORK_SESSION_TOOL, workSessionRequest, agentProfileContext } from './runtime-control.mjs'
|
|
15
15
|
import { createMastraMemory } from './memory.mjs'
|
|
16
|
+
import { createHarnessCheckpoints } from './harness-checkpoints.mjs'
|
|
16
17
|
import { createContextService } from './context-service.mjs'
|
|
17
18
|
import { contextToolGuide } from './context-tools.mjs'
|
|
19
|
+
import { loadProjectTickets, needsWorkContinuation } from './work-recovery.mjs'
|
|
18
20
|
import { toolWithoutCredentialInputs } from './codex-mcp-proxy.mjs'
|
|
19
21
|
import { compactAgentMessage } from './tool-policy.mjs'
|
|
20
22
|
import { repositoryHasPrPushAuthorization, configurePrPublishing } from './pr-push.mjs'
|
|
@@ -28,6 +30,8 @@ import { releaseAuthorizedPause } from './authorization-resume.mjs'
|
|
|
28
30
|
import { dedicatedChannel, repliesAfterSource } from './channel-routing.mjs'
|
|
29
31
|
import { createTaskTypeLookup, loadProjectTaskTypes } from './task-types.mjs'
|
|
30
32
|
import { createThreadContextResolver } from './thread-context.mjs'
|
|
33
|
+
import { compileConversationContext, createConversationReader, conversationAuthor, checkedConversationResult } from './conversation-context.mjs'
|
|
34
|
+
import { admitPeerTurn } from './peer-turns.mjs'
|
|
31
35
|
import { assignmentRequest, assignmentStatusOnly, routeAssignments } from './assignment-routing.mjs'
|
|
32
36
|
import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
|
|
33
37
|
import { createMastraAcpRunner } from './mastra-harness.mjs'
|
|
@@ -343,13 +347,13 @@ export async function runWatch({ flags }) {
|
|
|
343
347
|
}
|
|
344
348
|
|
|
345
349
|
// Claude keeps its native stream; every ACP provider uses the same Mastra harness.
|
|
346
|
-
export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, onEvent, systemPrompt, getContextEnvironment, canCoordinate = false, workspaceAvailable = !!workdir }) {
|
|
350
|
+
export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, onEvent, systemPrompt, getContextEnvironment, checkpoints, canCoordinate = false, workspaceAvailable = !!workdir }) {
|
|
347
351
|
const canCode = !!workdir
|
|
348
352
|
const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
|
|
349
353
|
// ACP providers run through Mastra's shared harness. Each queue worker gets
|
|
350
354
|
// its own ACP session and worktree, so cancellation, permissions and context
|
|
351
355
|
// cannot bleed across concurrently assigned tickets.
|
|
352
|
-
const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, canCoordinate, workspaceAvailable, maxCycleMs, log, debug, model, onTool, onEvent, systemPrompt, getContextEnvironment })
|
|
356
|
+
const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, canCoordinate, workspaceAvailable, maxCycleMs, log, debug, model, onTool, onEvent, systemPrompt, getContextEnvironment, checkpoints })
|
|
353
357
|
if (mastraRunner) return mastraRunner
|
|
354
358
|
let child = null
|
|
355
359
|
let privateMcpDir = ''
|
|
@@ -658,6 +662,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
658
662
|
claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
|
|
659
663
|
cfgKey: identifier, mcpConfig, workdir, workspaceAvailable: canCode, log, debug, model, systemPrompt, onEvent: runtimeEvent,
|
|
660
664
|
getContextEnvironment: () => contextService.environment(),
|
|
665
|
+
checkpoints: { load: scope => harnessCheckpoints.load(scope), save: (scope, value) => harnessCheckpoints.save(scope, value) },
|
|
661
666
|
}
|
|
662
667
|
// One watcher and one WS subscription, with a bounded pool of isolated coding
|
|
663
668
|
// runtimes. Replies serialize within a thread while independent threads run
|
|
@@ -784,6 +789,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
784
789
|
resourceId: identifier,
|
|
785
790
|
})
|
|
786
791
|
const contextBackend = new URL(backend || mcpUrl || 'http://local.invalid')
|
|
792
|
+
const harnessCheckpoints = createHarnessCheckpoints({ memory, resourceId: `${contextBackend.origin}${contextBackend.pathname.replace(/\/$/, '')}:${identifier}` })
|
|
787
793
|
const contextService = dependencies.contextService || createContextService({
|
|
788
794
|
directory: stateDir, resourceId: `${contextBackend.origin}${contextBackend.pathname.replace(/\/$/, '')}:${identifier}`,
|
|
789
795
|
loadTasks: () => loadPendingTickets(),
|
|
@@ -1005,6 +1011,21 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1005
1011
|
}).finally(() => { mcpToolDiscoveryPromise = null })
|
|
1006
1012
|
return mcpToolDiscoveryPromise
|
|
1007
1013
|
}
|
|
1014
|
+
const conversationReader = createConversationReader({ load: async (scope) => {
|
|
1015
|
+
const names = await discoverMcpTools()
|
|
1016
|
+
const ticket = scope.ticketId != null
|
|
1017
|
+
const tool = ticket ? 'list_task_comment_thread' : 'list_message_thread'
|
|
1018
|
+
if (!names.has(tool)) throw new Error('Conversation thread tool not advertised')
|
|
1019
|
+
const args = ticket ? { project_id: scope.projectId, ticket_id: scope.ticketId, comment_id: scope.threadId }
|
|
1020
|
+
: { channel_id: scope.channelId, message_id: scope.threadId }
|
|
1021
|
+
const [thread, surroundings] = await Promise.all([
|
|
1022
|
+
callMcpTool(tool, args).then(checkedConversationResult),
|
|
1023
|
+
!ticket && names.has('list_channel_messages')
|
|
1024
|
+
? callMcpTool('list_channel_messages', { channel_id: scope.channelId, limit: 12, before_id: scope.threadId }).then(checkedConversationResult).catch(() => null)
|
|
1025
|
+
: null,
|
|
1026
|
+
])
|
|
1027
|
+
return { thread, surroundings }
|
|
1028
|
+
} })
|
|
1008
1029
|
const callOptionalTicketComment = async ({ projectId, ticketId, content, parentId }) => {
|
|
1009
1030
|
let names
|
|
1010
1031
|
try { names = await discoverMcpTools() }
|
|
@@ -1055,10 +1076,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1055
1076
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
1056
1077
|
const groups = await mapConcurrent(projects.filter((project) => project?.id != null), 3, async (project) => {
|
|
1057
1078
|
const [tasksResult, typesResult] = await Promise.all([
|
|
1058
|
-
|
|
1079
|
+
loadProjectTickets({ projectId: project.id, read: async (name, args) => toolData(await callMcpReadWithRetry(name, args), name), listTools: () => mcpClient.listTools(), matches: resolveTaskOwner }),
|
|
1059
1080
|
taskTypeSets(project.id),
|
|
1060
1081
|
])
|
|
1061
|
-
const tasksData =
|
|
1082
|
+
const tasksData = { tasks: tasksResult }
|
|
1062
1083
|
const { done: doneIds, review: reviewIds } = typesResult
|
|
1063
1084
|
const tasks = await mapConcurrent(Array.isArray(tasksData.tasks) ? tasksData.tasks : [], 3, async (task) => {
|
|
1064
1085
|
await resolveTaskOwner(task)
|
|
@@ -1162,7 +1183,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1162
1183
|
save: saveOwnThread,
|
|
1163
1184
|
cancelled: (cid, root) => memory.has(threadControlKey(cid, root), 'cancelled'),
|
|
1164
1185
|
identity: () => ({ id: selfAgentId, identifier, slug, name: slug }),
|
|
1165
|
-
load: async (cid, root) =>
|
|
1186
|
+
load: async (cid, root) => (await conversationReader.read({ channelId: Number(cid), threadId: Number(root) })).thread,
|
|
1166
1187
|
})
|
|
1167
1188
|
const peerTurnKey = (surface, ...parts) => `peer-turn:${surface}:${parts.map((value) => String(value ?? '?')).join(':')}`
|
|
1168
1189
|
const ownTaskCommentKey = (projectId, ticketId, content) => {
|
|
@@ -1170,7 +1191,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1170
1191
|
return `own-task-comment:${projectId}:${ticketId}:${normalized.length}:${normalized.slice(0, 120)}:${normalized.slice(-120)}`
|
|
1171
1192
|
}
|
|
1172
1193
|
const rememberPeerReply = (key, refs = {}) => {
|
|
1173
|
-
if (key) memory.remember({ key, kind: 'peer-turn', state: 'replied', summary: 'Replied
|
|
1194
|
+
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 })
|
|
1174
1195
|
}
|
|
1175
1196
|
const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false, peerLoopKey = '', isCancelled = () => false }) => {
|
|
1176
1197
|
if (stopping) throw new Error('Watcher is stopping')
|
|
@@ -1190,6 +1211,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1190
1211
|
const controlKey = threadControlKey(channelId, parentId)
|
|
1191
1212
|
const suppressCancelled = () => {
|
|
1192
1213
|
if (isCancelled()) return { posted: false, reason: 'cycle-cancelled' }
|
|
1214
|
+
if (sourceMessageId != null && memory.has(`deleted-message:${channelId}:${sourceMessageId}`, 'deleted')) return { posted: false, reason: 'source-deleted' }
|
|
1193
1215
|
if (memory.has(deliveryKey, 'suppressed')) return { posted: false, reason: 'source-cancelled' }
|
|
1194
1216
|
if (allowCancelled || !controlKey || !memory.has(controlKey, 'cancelled')) return null
|
|
1195
1217
|
pendingReplyDeliveries.delete(deliveryKey)
|
|
@@ -1208,7 +1230,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1208
1230
|
}
|
|
1209
1231
|
const run = (async () => {
|
|
1210
1232
|
if (parentId != null) {
|
|
1211
|
-
const live =
|
|
1233
|
+
const live = checkedConversationResult(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
|
|
1212
1234
|
const plans = skipIfAnyAgentReply ? memory.recall({ channelId: Number(channelId), threadId: Number(parentId) }, 1000).filter(node => node.kind === 'work-plan' && node.meta?.sourceKey === sourceKey) : []
|
|
1213
1235
|
const rendered = repliesAfterSource(renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug }), sourceMessageId).filter(row => !plans.some(plan => normalizeRenderedMessageText(plan.summary) === normalizeRenderedMessageText(row.content)))
|
|
1214
1236
|
const duplicate = rendered.some((row) => normalizeRenderedMessageText(row.content) === normalizeRenderedMessageText(message))
|
|
@@ -1514,7 +1536,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1514
1536
|
try {
|
|
1515
1537
|
const assigned = []
|
|
1516
1538
|
const [tasksData, typesData] = await Promise.all([
|
|
1517
|
-
|
|
1539
|
+
loadProjectTickets({ projectId: project.id, read: async (name, args) => toolData(await callMcpReadWithRetry(name, args), name), listTools: () => mcpClient.listTools(), matches: resolveTaskOwner }).then(tasks => ({ tasks })),
|
|
1518
1540
|
taskTypeSets(project.id),
|
|
1519
1541
|
])
|
|
1520
1542
|
const { done: doneIds, review: reviewIds } = typesData
|
|
@@ -1673,6 +1695,13 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1673
1695
|
? { channelId: delivery.channelId, threadId: delivery.parentId }
|
|
1674
1696
|
: activeTaskRef ? { projectId: activeTaskRef.projectId, ticketId: activeTaskRef.ticketId } : {}
|
|
1675
1697
|
let recalled = ''
|
|
1698
|
+
runnerOptions.contextRefs = memoryRefs
|
|
1699
|
+
if (delivery?.sourceMessage) {
|
|
1700
|
+
let snapshot, unavailable = !!delivery.contextUnavailable
|
|
1701
|
+
try { if (!unavailable) snapshot = await conversationReader.read(memoryRefs) }
|
|
1702
|
+
catch (error) { unavailable = true; log('Compiled conversation history unavailable: ' + (error?.message || error)) }
|
|
1703
|
+
ctx.push(compileConversationContext({ ...snapshot, source: delivery.sourceMessage, scope: memoryRefs, unavailable }))
|
|
1704
|
+
}
|
|
1676
1705
|
try { recalled = await memory.context(memoryRefs) }
|
|
1677
1706
|
catch (error) { log('Optional history unavailable; agent can retrieve context with its tools: ' + (error?.message || error)) }
|
|
1678
1707
|
const capabilityContext = kind !== 'full' && canCode
|
|
@@ -1708,7 +1737,17 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1708
1737
|
cycleControl.planKey ||= 'plan:' + (delivery?.key || `${activeTaskRef?.projectId}:${activeTaskRef?.ticketId}:${verifiedTaskRevision}`)
|
|
1709
1738
|
cycleControl.outcome = 'running'
|
|
1710
1739
|
log(`cycle timing preflight=${Math.round(performance.now() - cycleStartedAt)}ms kind=${kind}`)
|
|
1711
|
-
|
|
1740
|
+
let result = await runner.runCycle(prompt, useModel, runnerOptions)
|
|
1741
|
+
// Stay in the same writable session and delivery scope after a premature
|
|
1742
|
+
// acknowledgment. Never escalate reply-only work or bypass cancellation.
|
|
1743
|
+
const unfinishedTurn = value => needsWorkContinuation(value, { pendingPlan: cycleControl.planEntries?.some(entry => ['pending', 'in_progress'].includes(entry.status)) })
|
|
1744
|
+
if (kind === 'full' && canCode && !cycleControl.cancelled && !stopping && unfinishedTurn(result)) {
|
|
1745
|
+
await cycleControl.planDelivery
|
|
1746
|
+
if (cycleControl.cancelled || stopping) return
|
|
1747
|
+
emit('cycle.continued', { cycleId: cycleControl.cycleId, reason: 'acknowledgement without an outcome', status: 'continued' })
|
|
1748
|
+
result = await runner.runCycle(`${prompt}\n\nCONTINUE THE ACCEPTED WORK: Your previous turn ended without a result: ${String(result.outputText || '(no final result)').slice(0, 700)}\nYour coding workspace is still available. An acknowledgement is not completion. Continue the original authorized task now, using alternate read tools if needed. Do not repeat the acknowledgement. Finish with a concrete result and verification, or a specific blocker with the checks attempted. Do not invent outcomes or bypass permissions.`, useModel, runnerOptions)
|
|
1749
|
+
if (unfinishedTurn(result)) result = { ...result, subtype: 'incomplete', is_error: true }
|
|
1750
|
+
}
|
|
1712
1751
|
if (result?.model) {
|
|
1713
1752
|
emit('models.applied', { requestedModel: useModel, model: result.model, tier: kind === 'full' ? 'coding' : 'reply', modelSettingsRevision: cycleModelRevision })
|
|
1714
1753
|
}
|
|
@@ -1812,6 +1851,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1812
1851
|
// Distill an event's WS payload into a directive the model can act on without
|
|
1813
1852
|
// any extra lookup.
|
|
1814
1853
|
const senderName = (m) => {
|
|
1854
|
+
const attributed = conversationAuthor(m || {})
|
|
1855
|
+
if (attributed.kind === 'agent' && attributed.name !== 'Unknown') return attributed.name
|
|
1815
1856
|
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)
|
|
1816
1857
|
if (!s) return ''
|
|
1817
1858
|
return (`${s.first_name || s.firstName || ''} ${s.last_name || s.lastName || ''}`.trim() || s.name || s.display_name || s.identifier || s.email || '')
|
|
@@ -1953,11 +1994,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1953
1994
|
if (messageSenderIsSelf(comment, { id: selfAgentId, identifier, slug, name })) return { status: 'ignored-self' }
|
|
1954
1995
|
const peer = messageSenderIsAgent(comment)
|
|
1955
1996
|
const loopKey = peerTurnKey('task-comment', projectId, ticketId, rootCommentId)
|
|
1956
|
-
if (peer && memory.has(loopKey, 'replied')) {
|
|
1957
|
-
log(`task:comment project=${projectId} ticket=${ticketId} ignored after one peer-agent reply; waiting for a human turn`)
|
|
1958
|
-
return { status: 'ignored-peer-loop' }
|
|
1959
|
-
}
|
|
1960
|
-
if (!peer) memory.remember({ key: loopKey, kind: 'peer-turn', state: 'human', summary: text, refs: { projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(rootCommentId) } })
|
|
1961
1997
|
const target = classifyConversationTarget(comment, [...selfAliases], { threadOwned: true })
|
|
1962
1998
|
if (target.action !== 'handle') {
|
|
1963
1999
|
log('task:comment ignored before model start (' + target.reason + ')')
|
|
@@ -1979,6 +2015,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1979
2015
|
log(`task:comment ticket #${ticketId} is not assigned to this agent — ignored`)
|
|
1980
2016
|
return { status: 'ignored-unassigned' }
|
|
1981
2017
|
}
|
|
2018
|
+
if (peer) {
|
|
2019
|
+
const meta = admitPeerTurn(memory.get(loopKey), text, target)
|
|
2020
|
+
if (!meta) return { status: 'ignored-peer-loop' }
|
|
2021
|
+
memory.remember({ key: loopKey, kind: 'peer-turn', state: 'reserved', meta })
|
|
2022
|
+
} else memory.remember({ key: loopKey, kind: 'peer-turn', state: 'human' })
|
|
2023
|
+
conversationReader.invalidate({ projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(rootCommentId) })
|
|
1982
2024
|
const who = senderName(comment)
|
|
1983
2025
|
const ticketSlug = ticketDisplaySlug(ticket)
|
|
1984
2026
|
memory.remember({ key: sourceKey, kind: 'task-comment', state: 'received', summary: text, refs: { projectId: Number(projectId), ticketId: Number(ticketId), messageId: Number(commentId), threadId: Number(rootCommentId) } })
|
|
@@ -1991,6 +2033,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1991
2033
|
sourceKey,
|
|
1992
2034
|
watcherOwned: true,
|
|
1993
2035
|
peerLoopKey: peer ? loopKey : '',
|
|
2036
|
+
sourceMessage: { ...comment, id: Number(commentId) },
|
|
1994
2037
|
}
|
|
1995
2038
|
const label = ticketSlug || String(ticket.title || 'the assigned ticket')
|
|
1996
2039
|
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.' : ''}`
|
|
@@ -2011,7 +2054,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2011
2054
|
const messageCandidate = [outer.message, data.message, payload.message].find((item) => item && typeof item === 'object' && !Array.isArray(item))
|
|
2012
2055
|
const directId = source.message_id ?? source.messageId ?? source.id
|
|
2013
2056
|
const directContent = source.content ?? source.body ?? source.text ?? (typeof source.message === 'string' ? source.message : null)
|
|
2014
|
-
|
|
2057
|
+
let message = messageCandidate || (directId != null && directContent != null ? { ...source, content: directContent } : {})
|
|
2058
|
+
const messageId = message.message_id ?? message.messageId ?? message.id
|
|
2059
|
+
// The documented mention envelope may provide the root outside message.
|
|
2060
|
+
// Explicit inner parent fields always win, including malformed ones.
|
|
2061
|
+
if (!['parent_id', 'parentId', 'thread_id', 'threadId', 'reply_to', 'replyTo'].some((key) => message[key] != null)
|
|
2062
|
+
&& validBackendId(source.thread_root_id) && String(source.thread_root_id) !== String(messageId)) message = { ...message, parent_id: Number(source.thread_root_id) }
|
|
2015
2063
|
const channelId = source.channel_id ?? source.channelId ?? source.channel?.id ?? message.channel_id ?? message.channelId ?? message.channel?.id
|
|
2016
2064
|
return { ...source, channel_id: channelId, message }
|
|
2017
2065
|
}
|
|
@@ -2019,6 +2067,31 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2019
2067
|
const mentionIntake = new Map()
|
|
2020
2068
|
function onEvent(k, d) {
|
|
2021
2069
|
if (stopping) return
|
|
2070
|
+
if (['channel:message:updated', 'channel:message:deleted', 'channel:message:reaction'].includes(k)) {
|
|
2071
|
+
const event = conversationEventData(d)
|
|
2072
|
+
if (validBackendId(event.channel_id)) {
|
|
2073
|
+
conversationReader.clearChannel(Number(event.channel_id))
|
|
2074
|
+
const mid = event.message?.id ?? event.message_id
|
|
2075
|
+
if (k === 'channel:message:deleted' && validBackendId(mid)) {
|
|
2076
|
+
memory.remember({ key: `deleted-message:${Number(event.channel_id)}:${Number(mid)}`, kind: 'message-deletion', state: 'deleted' })
|
|
2077
|
+
contextService.forget(`message:${Number(event.channel_id)}:${Number(mid)}`)
|
|
2078
|
+
contextService.forgetThread?.(Number(event.channel_id), Number(mid))
|
|
2079
|
+
cancelThread(Number(event.channel_id), Number(mid), 'Source message deleted')
|
|
2080
|
+
// A reply can itself be the accepted request. Cancel just its work,
|
|
2081
|
+
// without standing down unrelated requests in the parent thread.
|
|
2082
|
+
const fromDeletedSource = (delivery) => Number(delivery?.channelId) === Number(event.channel_id) && Number(delivery?.sourceMessageId) === Number(mid)
|
|
2083
|
+
for (const [key, delivery] of pendingReplyDeliveries) if (fromDeletedSource(delivery)) pendingReplyDeliveries.delete(key)
|
|
2084
|
+
for (const queue of Object.values(queues)) queue.cancel((item) => fromDeletedSource(item.delivery), (item) => {
|
|
2085
|
+
item.control.cancelled = true; item.control.wakeRecovery?.()
|
|
2086
|
+
void item.control.runner?.cancelCurrent?.('source-deleted')
|
|
2087
|
+
})
|
|
2088
|
+
persistReplay()
|
|
2089
|
+
} else if (k === 'channel:message:updated') {
|
|
2090
|
+
try { contextService.observe({ name: 'list_message_thread', args: { channel_id: Number(event.channel_id) }, result: event.message }) } catch { /* live read remains authoritative */ }
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
return
|
|
2094
|
+
}
|
|
2022
2095
|
if (k === 'task:comment') return trackOperation(handleTaskComment(d))
|
|
2023
2096
|
const conversationEvent = k === 'agent:mention' || k === 'agent:message' || k === 'channel:message'
|
|
2024
2097
|
if (!conversationEvent) return trackOperation(routeEvent(k, d))
|
|
@@ -2029,6 +2102,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2029
2102
|
const messageId = message.message_id ?? message.messageId ?? message.id
|
|
2030
2103
|
const malformedParent = parent == null && [message.parent_id, message.parentId, message.thread_id, message.threadId, message.reply_to, message.replyTo].some((value) => value != null)
|
|
2031
2104
|
if (malformedParent || !validBackendId(cid) || !validBackendId(messageId) || (parent != null && !validBackendId(parent))) { log('mention missing valid channel/message identity; ignored'); return }
|
|
2105
|
+
if (memory.has(`deleted-message:${Number(cid)}:${Number(messageId)}`, 'deleted') || parent != null && memory.has(`deleted-message:${Number(cid)}:${Number(parent)}`, 'deleted')) return
|
|
2106
|
+
conversationReader.clearChannel(Number(cid))
|
|
2032
2107
|
const key = `${cid}:${parent ?? message.message_id ?? message.messageId ?? message.id}`
|
|
2033
2108
|
// Keep redirects ordered while a legacy root is being verified. Other
|
|
2034
2109
|
// conversations can resolve and run independently.
|
|
@@ -2048,9 +2123,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2048
2123
|
// Organization fanout includes our own posts. Ignore stable self identity
|
|
2049
2124
|
// before recipient checks, replay guards or thread cancellation run.
|
|
2050
2125
|
if (messageSenderIsSelf(message, { id: selfAgentId, identifier })) return { status: 'ignored-self' }
|
|
2051
|
-
const preliminary = classifyConversationTarget(message, [...selfAliases])
|
|
2126
|
+
const preliminary = classifyConversationTarget(message, [...selfAliases], { threadOwned: parent != null && memory.has(threadControlKey(cid, parent), 'active') })
|
|
2052
2127
|
const irrelevant = preliminary.action === 'ignore' && preliminary.reason !== 'unaddressed-thread-activity'
|
|
2053
2128
|
let rootContent = ''
|
|
2129
|
+
let contextUnavailable = false
|
|
2054
2130
|
if (parent != null && !irrelevant && preliminary.action !== 'stand_down') {
|
|
2055
2131
|
const optional = preliminary.explicitSelf || memory.has(threadControlKey(cid, parent), 'active')
|
|
2056
2132
|
let contextTimer
|
|
@@ -2061,20 +2137,25 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2061
2137
|
// before a model starts. Delivery still re-reads the authoritative thread.
|
|
2062
2138
|
rootContent = optional ? await Promise.race([context, new Promise((resolve) => {
|
|
2063
2139
|
contextTimer = setTimeout(() => {
|
|
2140
|
+
contextUnavailable = true
|
|
2064
2141
|
log(`mention optional context pending channel=${cid} message=${messageId} — continuing with verified recipient`)
|
|
2065
2142
|
resolve('')
|
|
2066
2143
|
}, 1000)
|
|
2067
2144
|
})]) : await context
|
|
2068
2145
|
}
|
|
2069
2146
|
catch (error) {
|
|
2147
|
+
contextUnavailable = true
|
|
2070
2148
|
if (!preliminary.explicitSelf && !memory.has(threadControlKey(cid, parent), 'active')) throw error
|
|
2071
2149
|
log('optional root context unavailable; preserving verified recipient: ' + (error?.message || error))
|
|
2072
2150
|
}
|
|
2073
2151
|
finally { clearTimeout(contextTimer) }
|
|
2074
2152
|
}
|
|
2153
|
+
// Deletion may arrive during identity/history I/O, before there is a
|
|
2154
|
+
// queue entry for cancellation to find. Recheck at the admission boundary.
|
|
2155
|
+
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' }
|
|
2075
2156
|
if (!stopping) {
|
|
2076
2157
|
if (!irrelevant) log(`mention timing intake=${Math.round(performance.now() - intakeStartedAt)}ms source=${k} channel=${cid} message=${messageId}`)
|
|
2077
|
-
return routeEvent('agent:mention', { ...eventData, _sourceEvent: k, _threadRootContent: rootContent })
|
|
2158
|
+
return routeEvent('agent:mention', { ...eventData, _sourceEvent: k, _threadRootContent: rootContent, _contextUnavailable: contextUnavailable })
|
|
2078
2159
|
}
|
|
2079
2160
|
}).catch((error) => { log('mention thread verification failed: ' + (error?.message || error)); return { status: 'retry' } })
|
|
2080
2161
|
.finally(() => { if (mentionIntake.get(key) === next) mentionIntake.delete(key) })
|
|
@@ -2114,11 +2195,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2114
2195
|
log('agent:mention replay recovered for active owned thread')
|
|
2115
2196
|
}
|
|
2116
2197
|
const loopKey = cid != null && threadRoot != null ? peerTurnKey('channel', cid, threadRoot) : ''
|
|
2117
|
-
if (peerMessage && loopKey && memory.has(loopKey, 'replied')) {
|
|
2118
|
-
log('agent:mention ignored before model start (peer-loop-budget)')
|
|
2119
|
-
return { status: 'ignored-peer-loop' }
|
|
2120
|
-
}
|
|
2121
|
-
if (!peerMessage && loopKey) memory.remember({ key: loopKey, kind: 'peer-turn', state: 'human', summary: text, refs: { channelId: Number(cid), threadId: Number(threadRoot) } })
|
|
2122
2198
|
const target = classifyConversationTarget(msg, [...selfAliases], { threadOwned })
|
|
2123
2199
|
if (target.action === 'ignore') {
|
|
2124
2200
|
log('agent:mention ignored before model start (' + target.reason + ')')
|
|
@@ -2139,6 +2215,11 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2139
2215
|
}
|
|
2140
2216
|
return
|
|
2141
2217
|
}
|
|
2218
|
+
if (peerMessage && loopKey) {
|
|
2219
|
+
const meta = admitPeerTurn(memory.get(loopKey), text, target)
|
|
2220
|
+
if (!meta) { log('agent:mention ignored before model start (peer-loop-budget)'); return { status: 'ignored-peer-loop' } }
|
|
2221
|
+
memory.remember({ key: loopKey, kind: 'peer-turn', state: 'reserved', meta })
|
|
2222
|
+
} else if (loopKey) memory.remember({ key: loopKey, kind: 'peer-turn', state: 'human', summary: text, refs: { channelId: Number(cid), threadId: Number(threadRoot) } })
|
|
2142
2223
|
if (cid != null && threadRoot != null) activateThread(cid, threadRoot, text)
|
|
2143
2224
|
memory.remember({ key: sourceKey, kind: 'mention', state: 'received', summary: text, refs: { channelId: cid, threadId: threadRoot, messageId: mid } })
|
|
2144
2225
|
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 || '') } }) }
|
|
@@ -2147,10 +2228,11 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2147
2228
|
// redirect can cancel its process. The watcher delivers the final answer
|
|
2148
2229
|
// for every runtime after an authoritative last-moment thread check.
|
|
2149
2230
|
const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result', allowChannelMessaging = false) => cid != null
|
|
2150
|
-
? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: true, allowChannelMessaging, peerLoopKey: peerMessage ? loopKey : '' }
|
|
2231
|
+
? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, sourceMessage: msg, contextUnavailable: raw._contextUnavailable, watcherOwned: true, allowChannelMessaging, peerLoopKey: peerMessage ? loopKey : '' }
|
|
2151
2232
|
: null
|
|
2152
2233
|
// Under-the-hood model control from chat (view / switch the model the agent runs).
|
|
2153
2234
|
const mcmd = cid != null ? parseModelCmd(text) : null
|
|
2235
|
+
if (peerMessage && mcmd && !mcmd.report) { log('Peer agent cannot change local model configuration'); return }
|
|
2154
2236
|
if (mcmd) {
|
|
2155
2237
|
refreshModelSettings()
|
|
2156
2238
|
const thread = threadRoot != null ? `, parent_id ${threadRoot}` : ''
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Recover read paths, never replay a possibly successful mutation.
|
|
2
|
+
export async function loadProjectTickets({ projectId, read, listTools, matches = async () => true }) {
|
|
3
|
+
const rows = data => {
|
|
4
|
+
const value = Array.isArray(data) ? data : data?.tasks ?? data?.tickets ?? data?.data?.tasks ?? data?.data?.tickets;
|
|
5
|
+
if (!Array.isArray(value)) throw new Error('Ticket listing returned an unrecognized response');
|
|
6
|
+
return value.filter(row => row && (row.project_id == null || String(row.project_id) === String(projectId)));
|
|
7
|
+
};
|
|
8
|
+
let primary, failure;
|
|
9
|
+
try { primary = rows(await read('list_tasks', { project_id: projectId })); }
|
|
10
|
+
catch (error) { failure = error; }
|
|
11
|
+
if (primary?.length && (await Promise.all(primary.map(matches))).some(Boolean)) return primary;
|
|
12
|
+
let catalog;
|
|
13
|
+
try { catalog = await listTools(); }
|
|
14
|
+
catch (error) { throw failure || error; }
|
|
15
|
+
const alternate = catalog.find(tool => tool.name === 'get_tickets');
|
|
16
|
+
const schema = alternate?.inputSchema;
|
|
17
|
+
// Only use a published schema we can satisfy from authoritative context.
|
|
18
|
+
if (schema && (schema.required || []).every(key => key === 'project_id')) {
|
|
19
|
+
const args = schema.properties?.project_id ? { project_id: projectId } : {};
|
|
20
|
+
const alternateRows = rows(await read('get_tickets', args));
|
|
21
|
+
return [...new Map([...(primary || []), ...alternateRows].map(row => [String(row.id), row])).values()];
|
|
22
|
+
}
|
|
23
|
+
if (failure) throw failure;
|
|
24
|
+
return primary;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A narrow guard against promise-only final turns, not a tool-count quota.
|
|
28
|
+
// Completed explanations, verified no-ops and genuine blockers can end normally.
|
|
29
|
+
export function needsWorkContinuation(result, { pendingPlan = false } = {}) {
|
|
30
|
+
if (!result || result.is_error || !['ok', 'success'].includes(result.subtype) || result.workRequest || result.didCode || result.didRepoMutation || result.didResultMessage) return false;
|
|
31
|
+
const text = String(result.outputText || '').trim();
|
|
32
|
+
if (!text) return pendingPlan;
|
|
33
|
+
if (text.length > 700 || /\b(verified|passed|fixed|completed|found|failed|permission|approval|denied|blocked|missing|unavailable)\b/i.test(text)) return false;
|
|
34
|
+
return /^(?:(?:okay|ok|sure|understood|acknowledged|got it)[,.!—:\s]+)?(?:I['’]ll|I will|I['’]m going to|I am going to|I['’]m on it|I['’]ll take this on)\b/i.test(text);
|
|
35
|
+
}
|