openvisio-agent 0.22.0 → 0.24.0
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/README.md +24 -3
- package/USER_GUIDE.md +10 -0
- package/package.json +3 -2
- package/scenarios/runtime.scenarios.mjs +1 -1
- package/scenarios/workspace.scenarios.mjs +4 -3
- package/scripts/certify.mjs +1 -1
- package/scripts/smoke-context-search.mjs +33 -0
- package/src/activity-reporter.mjs +47 -0
- package/src/codex-mcp-proxy.mjs +35 -10
- package/src/context-search.mjs +168 -0
- package/src/context-service.mjs +103 -0
- package/src/context-tools.mjs +44 -0
- package/src/embedding-worker.mjs +29 -0
- package/src/mastra-harness.mjs +8 -4
- package/src/memory.mjs +3 -3
- package/src/opencode-config.mjs +1 -1
- package/src/transport-recovery.mjs +25 -0
- package/src/watch.mjs +161 -28
- package/src/ws.mjs +3 -2
- package/studio/app.mjs +1 -1
- package/studio/guide.html +7 -0
- package/studio/style.css +4 -3
package/src/watch.mjs
CHANGED
|
@@ -15,10 +15,15 @@ import { connectAgentWs, assertWebSocket } from './ws.mjs'
|
|
|
15
15
|
import { buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, conversationAsksPendingTickets, conversationCreatesTicket, conversationNeedsCode, failedTaskRevisionIsCurrent, mentionDedupeKeys, messageParentId, messageSenderIsSelf, normalizeRenderedMessageText, renderedAgentMessages, taskAgentId, taskAgentIdentifier, taskBelongsToAgent, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
|
|
16
16
|
import { WORK_SESSION_TOOL, workSessionRequest, agentProfileContext } from './runtime-control.mjs'
|
|
17
17
|
import { createMastraMemory } from './memory.mjs'
|
|
18
|
+
import { createContextService } from './context-service.mjs'
|
|
19
|
+
import { contextToolGuide } from './context-tools.mjs'
|
|
20
|
+
import { toolWithoutCredentialInputs } from './codex-mcp-proxy.mjs'
|
|
18
21
|
import { repositoryHasPrPushAuthorization, configurePrPublishing } from './pr-push.mjs'
|
|
19
22
|
import { createMcpHttpClient } from './mcp-http.mjs'
|
|
20
23
|
import { createCycleQueue } from './cycle-queue.mjs'
|
|
21
24
|
import { createRunnerPool } from './runner-pool.mjs'
|
|
25
|
+
import { createActivityReporter } from './activity-reporter.mjs'
|
|
26
|
+
import { interruptedRuntimeResult, isTransportInterruption, transportRecoveryDelays } from './transport-recovery.mjs'
|
|
22
27
|
import { mapConcurrent } from './concurrency.mjs'
|
|
23
28
|
import { releaseAuthorizedPause } from './authorization-resume.mjs'
|
|
24
29
|
import { dedicatedChannel, repliesAfterSource } from './channel-routing.mjs'
|
|
@@ -76,7 +81,7 @@ const REPLY_DISCIPLINE = [
|
|
|
76
81
|
' • 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.',
|
|
77
82
|
' • 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.',
|
|
78
83
|
' • EVENT NAMES ARE NOT OWNERSHIP. A transport may wake you for activity in a thread you once joined. Trust only the watcher\'s verified recipient decision for the current source message; never infer that every thread update is yours.',
|
|
79
|
-
' •
|
|
84
|
+
' • ACKNOWLEDGE TASKS, THEN DO THE WORK. Before starting an accepted task, publish a short, concrete plan using the native planning tool. The watcher sends one acknowledgement with your first 2-3 steps to the source thread. Do not post the same acknowledgement yourself. An acknowledgement or plan never completes the task: continue working, then give one distinct verified result or real blocker. Ordinary questions need only their answer; reconnects must not repeat the task acknowledgement.',
|
|
80
85
|
' • BE SURE BEFORE YOU SPEAK. Do not claim something is possible, done, or broken until you have actually verified it — call the tool, read the code, check the real state. Be willing to disagree or revise a conclusion when evidence changes. If you are unsure, investigate and distinguish a finding from an assumption.',
|
|
81
86
|
' • 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.',
|
|
82
87
|
' • 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.',
|
|
@@ -137,6 +142,8 @@ const CODE_CHARTER = [
|
|
|
137
142
|
|
|
138
143
|
const CODE_FULL = [
|
|
139
144
|
'Handle the supplied verified ticket or source request. You decide how to investigate, which tools to use, what context to retain or compact, and when the work is ready for a final response.',
|
|
145
|
+
'Before starting the task, use your native planning tool (update_plan, TodoWrite, or todowrite when advertised) to publish 2-3 short, concrete steps based on the supplied request. The watcher delivers this as your acknowledgement and brief plan. Keep working after the plan; only your final response ends the turn. Do not repeat the acknowledgement after a transport recovery.',
|
|
146
|
+
'DELEGATION: Use native sub-agents for independent subtasks when they can shorten the work. Discover the runtime tools actually available: Codex spawn_agent/wait/send_input/close_agent, Claude Agent/Task, or OpenCode task. Give each child a bounded objective, relevant context, and explicit file ownership or a separate worktree. Work on another independent part while they run. Children inherit the current task authority; they cannot bypass a policy block, publish unrelated work, or answer the teammate separately. Review and integrate their results, close finished children, and deliver one parent response. Keep delegation bounded (normally at most three children); do not delegate trivial or dependent steps. If the runtime lacks delegation, continue locally and be clear about that limit.',
|
|
140
147
|
'There is no required sequence of tools, code edits, ticket updates, or PR creation. Research, audits, explanations, coordination, and already-satisfied requests may finish without repository changes. Choose validation and delivery appropriate to the actual request.',
|
|
141
148
|
'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.',
|
|
142
149
|
'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.',
|
|
@@ -149,7 +156,7 @@ const CODE_FAST = [
|
|
|
149
156
|
' • IF a specific mention/message FOR YOU is given above: reply to THAT ONE message exactly once with post_message, then STOP. Do NOT call poll_inbox and do NOT answer anything else this cycle — polling would re-surface the same message and make you double-post.',
|
|
150
157
|
' • IF NO specific mention is given above: call poll_inbox and reply only to items directed at YOU (asks you something, or responds to your own message) — SKIP chatter aimed at someone else / another agent; at most one reply per channel.',
|
|
151
158
|
'Explore the request and its context. If local execution or edits are needed, use openvisio_request_work_session when advertised to continue in your own coding workspace. Do not turn an internal scheduling choice into a reassignment request.',
|
|
152
|
-
'For a non-code question, post ONE answer and stop. For code work,
|
|
159
|
+
'For a non-code question, post ONE answer and stop. For code work, continue into your coding workspace, publish the short task plan for watcher acknowledgement, and keep working; then give one distinct final result with the evidence or a real blocker. Never repeat the acknowledgement or result.',
|
|
153
160
|
].join('\n')
|
|
154
161
|
|
|
155
162
|
// ── Workspace-ethics cycles (both chat-only + code agents) ───────────────────
|
|
@@ -164,7 +171,7 @@ const INTRO = [
|
|
|
164
171
|
const SWEEP = [
|
|
165
172
|
'DAILY CATCH-UP — you may have missed items while offline. Prioritize TASKS.',
|
|
166
173
|
'Use the available task/inbox tools. If get_marching_orders/poll_inbox are absent, use list_agents + list_projects + list_tasks to find tasks assigned to your agent identity, then:',
|
|
167
|
-
' 1. For every task assigned to YOU that you have NOT started:
|
|
174
|
+
' 1. For every task assigned to YOU that you have NOT started: publish a short task plan for watcher acknowledgement, then do the work end-to-end. Use update_ticket when appropriate and report a verified result or concrete blocker. Skip tasks assigned to other agents.',
|
|
168
175
|
' 2. Answer only the @mentions / follow-ups that were directed at YOU and that you have not already answered — at most one reply per channel. Do not reply to threads aimed at someone else.',
|
|
169
176
|
'If there is genuinely nothing outstanding, STOP silently — do NOT post a "nothing to do" message.',
|
|
170
177
|
].join('\n')
|
|
@@ -330,16 +337,18 @@ export async function runWatch({ flags }) {
|
|
|
330
337
|
}
|
|
331
338
|
|
|
332
339
|
// Claude keeps its native stream; Codex and OpenCode use Mastra ACP.
|
|
333
|
-
export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, onEvent, systemPrompt, canCoordinate = false, workspaceAvailable = !!workdir }) {
|
|
340
|
+
export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, onEvent, systemPrompt, getContextEnvironment, canCoordinate = false, workspaceAvailable = !!workdir }) {
|
|
334
341
|
const canCode = !!workdir
|
|
335
342
|
const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
|
|
336
343
|
// Codex and OpenCode run through Mastra's ACP harness. Each queue worker gets
|
|
337
344
|
// its own ACP session and worktree, so cancellation, permissions and context
|
|
338
345
|
// cannot bleed across concurrently assigned tickets.
|
|
339
|
-
const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, canCoordinate, workspaceAvailable, maxCycleMs, log, debug, model, onTool, onEvent, systemPrompt })
|
|
346
|
+
const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, canCoordinate, workspaceAvailable, maxCycleMs, log, debug, model, onTool, onEvent, systemPrompt, getContextEnvironment })
|
|
340
347
|
if (mastraRunner) return mastraRunner
|
|
341
348
|
let child = null
|
|
342
349
|
let privateMcpDir = ''
|
|
350
|
+
let contextEnvironment = {}
|
|
351
|
+
let disabledMcpTools = []
|
|
343
352
|
// The model the CURRENT session was spawned with. runCycle can pass a different
|
|
344
353
|
// model per cycle (cheap for chat, stronger for code) — a change recycles the
|
|
345
354
|
// session so the new model takes effect.
|
|
@@ -424,6 +433,8 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
|
|
|
424
433
|
OPENVISIO_CODEX_IDENTIFIER: mcpHeaders['x-agent-identifier'],
|
|
425
434
|
OPENVISIO_CAN_CODE: String(canCode),
|
|
426
435
|
OPENVISIO_WORKSPACE_AVAILABLE: String(workspaceAvailable),
|
|
436
|
+
OPENVISIO_CODEX_DISABLED_TOOLS: JSON.stringify(disabledMcpTools),
|
|
437
|
+
...contextEnvironment,
|
|
427
438
|
},
|
|
428
439
|
} } }, true)
|
|
429
440
|
}
|
|
@@ -503,7 +514,14 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
|
|
|
503
514
|
: ' [CONTEXT session — advertised tools, reading, search, and planning]'))
|
|
504
515
|
}
|
|
505
516
|
|
|
506
|
-
function runCycle(prompt, cycleModel, cycleOptions = {}) {
|
|
517
|
+
async function runCycle(prompt, cycleModel, cycleOptions = {}) {
|
|
518
|
+
contextEnvironment = await getContextEnvironment?.() || {}
|
|
519
|
+
const nextDisabled = [...(cycleOptions.disabledMcpTools || [])].sort()
|
|
520
|
+
if (JSON.stringify(nextDisabled) !== JSON.stringify(disabledMcpTools) && child) {
|
|
521
|
+
const previous = child; child = null
|
|
522
|
+
await stopModelProcess(previous)
|
|
523
|
+
}
|
|
524
|
+
disabledMcpTools = nextDisabled
|
|
507
525
|
return new Promise((resolve) => {
|
|
508
526
|
turnCycleId = cycleOptions.cycleId ?? null
|
|
509
527
|
// A per-cycle model override (e.g. chat on a cheaper model than code) — a
|
|
@@ -591,6 +609,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
591
609
|
const journal = runtimeJournal({ stateDir, agent: { identifier, slug, provider: agent }, secrets: [apiKey] }, dependencies.journal)
|
|
592
610
|
const emit = (type, data = {}) => journal.emit(type, { watcherPid: process.pid, ...data })
|
|
593
611
|
let cycleSequence = 0
|
|
612
|
+
const liveCycles = new Map()
|
|
594
613
|
let mcpSequence = 0
|
|
595
614
|
const cycleContext = new AsyncLocalStorage()
|
|
596
615
|
emit('watcher.started', { status: 'running', model, chatModel, workdir: workdir || '' })
|
|
@@ -598,10 +617,23 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
598
617
|
// Activity belongs to an individual cycle. Independent ticket workers must
|
|
599
618
|
// not clear or overwrite each other's status targets.
|
|
600
619
|
const replyStatusTargets = new Set()
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
620
|
+
const activity = dependencies.activityReporter || createActivityReporter({ backend, apiKey, identifier, log })
|
|
621
|
+
const sendStatus = (channelId, state) => {
|
|
622
|
+
const id = cycleContext.getStore()?.cycleId
|
|
623
|
+
const live = liveCycles.get(id)
|
|
624
|
+
if (live && !live.control.cancelled) activity.set(id, live.control.statusTargets.size ? live.control.statusTargets : [channelId], state)
|
|
625
|
+
}
|
|
604
626
|
const emitStatusTargets = (targets, state) => { for (const c of targets) sendStatus(c, state) }
|
|
627
|
+
const runtimeEvent = (type, data = {}) => {
|
|
628
|
+
emit(type, data)
|
|
629
|
+
const live = liveCycles.get(data.cycleId)
|
|
630
|
+
if (!live || live.control.cancelled || stopping) return
|
|
631
|
+
if (type === 'tool.started' || type === 'tool.updated') activity.set(data.cycleId, live.control.statusTargets, 'working')
|
|
632
|
+
if (type === 'output.progress') activity.set(data.cycleId, live.control.statusTargets, 'typing')
|
|
633
|
+
if (type === 'plan.updated' && live.data.kind === 'full' && data.entries?.some(entry => typeof entry?.content === 'string' && entry.content.trim()) && !live.control.planDelivery) {
|
|
634
|
+
live.control.planDelivery = trackOperation(publishWorkPlan(live, data.entries)).catch(error => log('Task plan delivery unavailable: ' + (error?.message || error)))
|
|
635
|
+
}
|
|
636
|
+
}
|
|
605
637
|
const canCode = !!workdir
|
|
606
638
|
// The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
|
|
607
639
|
// agent_identifier + agent_api_key as arguments. Hand them over up front.
|
|
@@ -612,13 +644,17 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
612
644
|
const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
|
|
613
645
|
const runnerOptions = {
|
|
614
646
|
claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
|
|
615
|
-
cfgKey: identifier, mcpConfig, workdir, workspaceAvailable: canCode, log, debug, model, systemPrompt, onEvent:
|
|
647
|
+
cfgKey: identifier, mcpConfig, workdir, workspaceAvailable: canCode, log, debug, model, systemPrompt, onEvent: runtimeEvent,
|
|
648
|
+
getContextEnvironment: () => contextService.environment(),
|
|
616
649
|
}
|
|
617
650
|
// One watcher and one WS subscription, with a bounded pool of isolated coding
|
|
618
651
|
// runtimes. Replies serialize within a thread while independent threads run
|
|
619
652
|
// concurrently behind the same delivery guards.
|
|
620
653
|
const MAX_CONCURRENT_WORKERS = 3
|
|
621
654
|
const activeWorkRunners = new Set()
|
|
655
|
+
const recoveryWaiters = new Set()
|
|
656
|
+
const recoveryDelays = dependencies.transportRecoveryDelays || transportRecoveryDelays
|
|
657
|
+
let eventConnection = 'unknown'
|
|
622
658
|
const createWorkRunner = (control, workerWorkdir = '') => makeRunner({
|
|
623
659
|
...runnerOptions,
|
|
624
660
|
...(workerWorkdir ? { workdir: workerWorkdir } : {}),
|
|
@@ -640,12 +676,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
640
676
|
const codexPushGuide = agent === 'codex' && canCode
|
|
641
677
|
? '\n\nCODEX PR DELIVERY: when the repository exists in the local workspace, use that clone for branch creation, edits, tests, and commits; do not inspect or mutate it through linked-codebase MCP tools. To publish the local agent/* branch, run `openvisio-agent push-pr-branch` from the repository, then open the PR with `gh pr create`. The helper can only push HEAD to the matching agent/* branch on the exact authorized origin. If it reports OPENVISIO_PR_PUSH_AUTH_REQUIRED, do not retry or route around it. PR publishing is enabled during connection setup for the coding workspace and its worktrees. If authorization is missing outside that workspace, report the exact repository configuration issue once; never tell the user to restart the watcher. Use list_codebases/create_codebase_branch/create_codebase_commit/create_pull_request only as a fallback when the repository cannot be obtained locally.'
|
|
642
678
|
: ''
|
|
643
|
-
const backendToolRule = 'TOOL DISCOVERY: use the tools and resources actually advertised by your connected runtime. Native tool_search, MCP resources, history, search, planning, skills, and context compaction are available when the runtime supports them. Discover an absent action before deciding it is unavailable; never invent a tool or its schema. The supplied event identifies the request, so further discovery is your choice when it helps the task.'
|
|
679
|
+
const backendToolRule = 'TOOL DISCOVERY: use the tools and resources actually advertised by your connected runtime. Native tool_search, MCP resources, history, search, planning, skills, and context compaction are available when the runtime supports them. Discover an absent action before deciding it is unavailable; never invent a tool or its schema. The supplied event identifies the request, so further discovery is your choice when it helps the task.' + '\n\n' + contextToolGuide
|
|
644
680
|
const studioGuide = `\n\nAGENT STUDIO: When a teammate asks to inspect agent activity or change model settings, offer [Open Agent Studio](http://127.0.0.1:4317/#agent=${encodeURIComponent(identifier)}&settings=1). The app renders this link as a button. Studio manages agents on the teammate’s own computer; it does not connect to another teammate’s machine. Prefer this UI over asking people to run model-setting CLI commands.`
|
|
645
681
|
const fullPrompt = (canCode ? CODE_FULL + codexPushGuide : 'Handle the supplied verified backend ticket with the available OpenVisio tools. Update or comment on the ticket as requested, do not claim repository work in chat-only mode, and stop after the verified action.') + '\n\n' + backendToolRule + studioGuide
|
|
646
682
|
const fastPrompt = (canCode ? CODE_FAST : CYCLE_FAST) + '\n\n' + backendToolRule + studioGuide
|
|
647
683
|
const coordinatePrompt = COORDINATE + '\n\n' + backendToolRule + studioGuide
|
|
648
|
-
const guardedReplyPrompt = `WATCHER-DELIVERED REPLY: the watcher has already verified that the current source message is addressed to you. Your current OpenVisio agent identifier is "${identifier}"; do not call list_agents merely to rediscover yourself. Return the final reply for watcher delivery instead of calling post_message for the same result. Discover tools and retrieve additional context as needed. OpenVisio team-state tools are allowed: when the answer depends on live projects, assignments, tickets, or status, use list_projects/list_tasks/get_ticket as needed and never ask the teammate for a slug that those tools can resolve. A failed read-only discovery call is not a completed action and must not be described as an intervention-level blocker; continue with available live data or state the narrow fact you could not verify. Return one natural, context-specific final response with enough detail to address the request. Do not echo the request
|
|
684
|
+
const guardedReplyPrompt = `WATCHER-DELIVERED REPLY: the watcher has already verified that the current source message is addressed to you. Your current OpenVisio agent identifier is "${identifier}"; do not call list_agents merely to rediscover yourself. Return the final reply for watcher delivery instead of calling post_message for the same result. Discover tools and retrieve additional context as needed. OpenVisio team-state tools are allowed: when the answer depends on live projects, assignments, tickets, or status, use list_projects/list_tasks/get_ticket as needed and never ask the teammate for a slug that those tools can resolve. A failed read-only discovery call is not a completed action and must not be described as an intervention-level blocker; continue with available live data or state the narrow fact you could not verify. Return one natural, context-specific final response with enough detail to address the request. Do not echo the request or replace the final answer with a plan. Task acknowledgements are delivered separately from your native plan; ordinary questions need only their answer. If the supplied context says the work was completed, lead with the verified result; if it is blocked, name only the real blocker and next action.` + '\n\n' + backendToolRule + studioGuide
|
|
649
685
|
// Live model state — changeable at runtime by the in-chat `/model` command.
|
|
650
686
|
// codeModel drives full/sweep cycles; chatModel (if set) the lighter fast/intro
|
|
651
687
|
// ones, so routine chatter can run cheaper than real code work.
|
|
@@ -674,7 +710,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
674
710
|
const runner = workRunners.acquire(sessionKey, { control: item.control, workdir: item.workdir })
|
|
675
711
|
item.control.runner = runner
|
|
676
712
|
activeWorkRunners.add(runner)
|
|
677
|
-
try { return await cycleContext.run({ cycleId: item.control.cycleId }, () =>
|
|
713
|
+
try { return await cycleContext.run({ cycleId: item.control.cycleId }, () => executeRecoverableCycle(item, runner)) }
|
|
678
714
|
finally { workRunners.release(sessionKey); activeWorkRunners.delete(runner); item.control.runner = null }
|
|
679
715
|
},
|
|
680
716
|
onError: (error, item) => {
|
|
@@ -721,6 +757,13 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
721
757
|
databasePath: join(stateDir, 'watch-' + slug + '-mastra.db'),
|
|
722
758
|
resourceId: identifier,
|
|
723
759
|
})
|
|
760
|
+
const contextBackend = new URL(backend || mcpUrl || 'http://local.invalid')
|
|
761
|
+
const contextService = dependencies.contextService || createContextService({
|
|
762
|
+
directory: stateDir, resourceId: `${contextBackend.origin}${contextBackend.pathname.replace(/\/$/, '')}:${identifier}`,
|
|
763
|
+
loadTasks: () => loadPendingTickets(),
|
|
764
|
+
listTools: async () => (await mcpClient.listTools()).map(toolWithoutCredentialInputs),
|
|
765
|
+
redact: (text) => apiKey ? String(text).split(apiKey).join('[redacted]') : String(text),
|
|
766
|
+
})
|
|
724
767
|
// Completion delivery is runtime-owned for assigned coding work. Persist both
|
|
725
768
|
// pending and delivered keys so a reconnect can finish a missed notification
|
|
726
769
|
// without re-running the model or posting the same result twice.
|
|
@@ -729,7 +772,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
729
772
|
const finishedTaskVersions = new Map(Array.isArray(replayState.finishedTaskVersions) ? replayState.finishedTaskVersions : [])
|
|
730
773
|
const reportedCompletions = new Set(Array.isArray(replayState.reportedCompletions) ? replayState.reportedCompletions : [])
|
|
731
774
|
const reportedTaskComments = new Set(Array.isArray(replayState.reportedTaskComments) ? replayState.reportedTaskComments : [])
|
|
732
|
-
//
|
|
775
|
+
// Unrecoverable runtime failures are held at the ticket revision that produced them.
|
|
733
776
|
// This is distinct from a policy block: any later human ticket change resumes
|
|
734
777
|
// the work, but reconnects and the watcher's own blocker update do not.
|
|
735
778
|
const failedTaskVersions = new Map(replayState.completionPolicy === 'agent' && Array.isArray(replayState.failedTaskVersions) ? replayState.failedTaskVersions : [])
|
|
@@ -856,6 +899,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
856
899
|
emit('tool.started', { ...metadata, status: 'in_progress' })
|
|
857
900
|
return trackOperation(Promise.resolve().then(() => mcpClient.callTool(name, args)).then((result) => {
|
|
858
901
|
emit('tool.finished', { ...metadata, status: 'completed' })
|
|
902
|
+
try { contextService.observe({ name, args, result }) } catch { /* optional conversation indexing */ }
|
|
859
903
|
return result
|
|
860
904
|
}, (error) => {
|
|
861
905
|
emit('tool.finished', { ...metadata, status: 'failed' })
|
|
@@ -1015,6 +1059,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1015
1059
|
for (const [laneName, queue] of Object.entries(queues)) {
|
|
1016
1060
|
queue.cancel((item) => sameDeliveryThread(item.delivery, channelId, parentId), (item) => {
|
|
1017
1061
|
item.control.cancelled = true
|
|
1062
|
+
item.control.wakeRecovery?.()
|
|
1018
1063
|
log(`${laneName} lane cancelled by a newer redirect/stand-down in thread ${parentId}`)
|
|
1019
1064
|
void item.control.runner?.cancelCurrent?.('thread-cancelled')
|
|
1020
1065
|
})
|
|
@@ -1043,7 +1088,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1043
1088
|
identity: () => ({ id: selfAgentId, identifier, slug, name: slug }),
|
|
1044
1089
|
load: async (cid, root) => toolData(await callMcpTool('list_message_thread', { channel_id: Number(cid), message_id: Number(root) })),
|
|
1045
1090
|
})
|
|
1046
|
-
const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false }) => {
|
|
1091
|
+
const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false, isCancelled = () => false }) => {
|
|
1047
1092
|
if (stopping) throw new Error('Watcher is stopping')
|
|
1048
1093
|
const deliveryKey = String(key || '')
|
|
1049
1094
|
if (parentId == null) {
|
|
@@ -1060,6 +1105,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1060
1105
|
if (messageDeliveries.has(deliveryKey)) return messageDeliveries.get(deliveryKey)
|
|
1061
1106
|
const controlKey = threadControlKey(channelId, parentId)
|
|
1062
1107
|
const suppressCancelled = () => {
|
|
1108
|
+
if (isCancelled()) return { posted: false, reason: 'cycle-cancelled' }
|
|
1063
1109
|
if (memory.has(deliveryKey, 'suppressed')) return { posted: false, reason: 'source-cancelled' }
|
|
1064
1110
|
if (allowCancelled || !controlKey || !memory.has(controlKey, 'cancelled')) return null
|
|
1065
1111
|
pendingReplyDeliveries.delete(deliveryKey)
|
|
@@ -1079,7 +1125,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1079
1125
|
const run = (async () => {
|
|
1080
1126
|
if (parentId != null) {
|
|
1081
1127
|
const live = toolData(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
|
|
1082
|
-
const
|
|
1128
|
+
const plans = skipIfAnyAgentReply ? memory.recall({ channelId: Number(channelId), threadId: Number(parentId) }, 1000).filter(node => node.kind === 'work-plan' && node.meta?.sourceKey === sourceKey) : []
|
|
1129
|
+
const rendered = repliesAfterSource(renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug }), sourceMessageId).filter(row => !plans.some(plan => normalizeRenderedMessageText(plan.summary) === normalizeRenderedMessageText(row.content)))
|
|
1083
1130
|
const duplicate = rendered.some((row) => normalizeRenderedMessageText(row.content) === normalizeRenderedMessageText(message))
|
|
1084
1131
|
if (duplicate || (skipIfAnyAgentReply && rendered.length)) {
|
|
1085
1132
|
deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
|
|
@@ -1104,6 +1151,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1104
1151
|
content: message,
|
|
1105
1152
|
}))
|
|
1106
1153
|
const postedId = result.id ?? result.message?.id ?? result.message_id
|
|
1154
|
+
if (postedId != null) contextService.observe({ name: 'post_message', args: { channel_id: channelId, parent_id: parentId, content: message }, result: { id: postedId } })
|
|
1107
1155
|
if (parentId == null && postedId != null) saveOwnThread(channelId, postedId, message)
|
|
1108
1156
|
deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
|
|
1109
1157
|
memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: message, refs: { channelId: Number(channelId), ...(parentId != null ? { threadId: Number(parentId) } : {}), ...(Number.isFinite(Number(projectId)) ? { projectId: Number(projectId) } : {}) }, meta: { messageId: result.id ?? result.message?.id ?? null } })
|
|
@@ -1117,6 +1165,26 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1117
1165
|
return run
|
|
1118
1166
|
}
|
|
1119
1167
|
|
|
1168
|
+
async function publishWorkPlan(live, entries) {
|
|
1169
|
+
const { control, delivery, taskRef } = live
|
|
1170
|
+
const steps = (Array.isArray(entries) ? entries : []).filter(entry => entry && typeof entry.content === 'string' && entry.content.trim()).slice(0, 3).map(entry => entry.content.trim().slice(0, 240))
|
|
1171
|
+
if (!steps.length || control.cancelled || !control.planKey || memory.has(control.planKey)) return
|
|
1172
|
+
const content = `${control.taskLabel ? `I'm on ${control.taskLabel}.` : "I'll take this on."}\n\n${steps.map((step, index) => `${index + 1}. ${step}`).join('\n')}`
|
|
1173
|
+
const channelId = delivery?.channelId ?? taskRef?.channelId ?? await projectStatusChannel(taskRef?.projectId)
|
|
1174
|
+
if (channelId == null || control.cancelled) return
|
|
1175
|
+
const refs = { channelId: Number(channelId), ...(delivery?.parentId != null ? { threadId: Number(delivery.parentId) } : {}) }
|
|
1176
|
+
// Remember intent before a potentially ambiguous write. A later final reply
|
|
1177
|
+
// must not be mistaken for a duplicate of this plan, even after reconnect.
|
|
1178
|
+
memory.remember({ key: control.planKey, kind: 'work-plan', state: 'attempted', summary: content, refs, meta: { sourceKey: delivery?.sourceKey || '' } })
|
|
1179
|
+
try {
|
|
1180
|
+
await postMessageOnce({ key: control.planKey + ':delivery', channelId, parentId: delivery?.parentId, projectId: taskRef?.projectId, sourceKey: delivery?.sourceKey, sourceMessageId: delivery?.sourceMessageId, content, isCancelled: () => control.cancelled })
|
|
1181
|
+
} finally {
|
|
1182
|
+
// An acknowledgement is only useful before work completes. Never leave a
|
|
1183
|
+
// delayed pickup message queued behind a delivered final response.
|
|
1184
|
+
pendingReplyDeliveries.delete(control.planKey + ':delivery'); persistReplay()
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1120
1188
|
let deliveryProbeBusy = false
|
|
1121
1189
|
const flushPendingReplies = async () => {
|
|
1122
1190
|
if (stopping || deliveryProbeBusy) return
|
|
@@ -1473,21 +1541,57 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1473
1541
|
const cycleId = `${journal.runId}:cycle:${++cycleSequence}`
|
|
1474
1542
|
const control = { cycleId, outcome: 'queued', cancelled: false, runner: null, statusTargets: new Set(), enqueuedAt: performance.now() }
|
|
1475
1543
|
const cycleData = { cycleId, kind, lane: laneName, model: kind === 'full' ? codeModel : liteModel, workdir: itemWorkdir || (laneName === 'work' ? workdir : ''), ...(taskRef ? { ticket: { projectId: taskRef.projectId, ticketId: taskRef.ticketId } } : {}), ...(delivery ? { thread: { channelId: delivery.channelId, threadId: delivery.parentId } } : {}) }
|
|
1544
|
+
liveCycles.set(cycleId, { data: cycleData, control, taskRef, delivery })
|
|
1476
1545
|
emit('cycle.queued', { ...cycleData, status: 'queued' })
|
|
1477
1546
|
return queues[laneName].enqueue({ kind, context, targetChannels, taskRef, delivery, workdir: itemWorkdir, control }, key).then((result) => {
|
|
1478
1547
|
const status = control.cancelled || result?.status === 'canceled' ? 'canceled' : result?.status === 'failed' ? 'error' : control.outcome
|
|
1479
1548
|
emit(status === 'canceled' ? 'cycle.cancelled' : 'cycle.finished', { ...cycleData, status, finishedBy: control.finishedBy || 'runtime', durationMs: Math.round(performance.now() - (control.startedAt ?? control.enqueuedAt)) })
|
|
1549
|
+
liveCycles.delete(cycleId)
|
|
1550
|
+
activity.clear(cycleId)
|
|
1480
1551
|
return result
|
|
1481
1552
|
})
|
|
1482
1553
|
}
|
|
1483
1554
|
|
|
1555
|
+
async function executeRecoverableCycle(item, runner) {
|
|
1556
|
+
const { control } = item
|
|
1557
|
+
let partialOutput = ''
|
|
1558
|
+
for (let attempt = 1; !stopping && !control.cancelled; attempt++) {
|
|
1559
|
+
control.attempt = attempt
|
|
1560
|
+
let interruption
|
|
1561
|
+
const continuation = attempt === 1 ? '' : '\n\nTRANSPORT RECOVERY: The previous runtime was interrupted before an agent final response. Continue the original request in the existing workspace. Inspect current files, git state, and live task/thread history first; edits and remote writes may already have succeeded. Do not blindly repeat commits, ticket creation, comments, or other writes. Verify uncertain outcomes before acting. You decide the remaining work and when to finish.' + (partialOutput ? '\nPrevious partial output (not a final result):\n' + partialOutput : '')
|
|
1562
|
+
try {
|
|
1563
|
+
interruption = await executeCycle(item.kind, item.context + continuation, item.targetChannels, item.taskRef, item.delivery, runner, control, item.workdir)
|
|
1564
|
+
if (!interruption?.transportInterrupted) return
|
|
1565
|
+
partialOutput = String(interruption.outputText || partialOutput).slice(-12000)
|
|
1566
|
+
} catch (error) {
|
|
1567
|
+
if (stopping || control.cancelled) return
|
|
1568
|
+
if (attempt > recoveryDelays.length || !isTransportInterruption(error)) throw error
|
|
1569
|
+
}
|
|
1570
|
+
if (stopping || control.cancelled) return
|
|
1571
|
+
control.outcome = 'recovering'
|
|
1572
|
+
activity.clear(control.cycleId)
|
|
1573
|
+
const delayMs = recoveryDelays[attempt - 1]
|
|
1574
|
+
emit('cycle.recovering', { cycleId: control.cycleId, status: 'recovering', attempt, retryInMs: delayMs, reason: 'Runtime connection interrupted; preserving accepted work' })
|
|
1575
|
+
log(`coding runtime connection interrupted; resuming attempt ${attempt + 1} in ${delayMs / 1000}s or on reconnect`)
|
|
1576
|
+
await new Promise((resolve) => {
|
|
1577
|
+
const wake = () => { clearTimeout(timer); recoveryWaiters.delete(wake); control.wakeRecovery = null; resolve() }
|
|
1578
|
+
// A known event-channel outage can last longer than the retry budget.
|
|
1579
|
+
// Keep accepted work pending until reconnect instead of burning model
|
|
1580
|
+
// attempts while offline. Provider-only failures still use backoff.
|
|
1581
|
+
const timer = setTimeout(() => { if (eventConnection !== 'disconnected') wake() }, delayMs)
|
|
1582
|
+
recoveryWaiters.add(wake)
|
|
1583
|
+
control.wakeRecovery = wake
|
|
1584
|
+
})
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1484
1588
|
async function executeCycle(kind, context, targetChannels = [], taskRef = null, delivery = null, runner, control, preparedWorkdir = '') {
|
|
1485
1589
|
refreshModelSettings()
|
|
1486
1590
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
1487
1591
|
const cycleControl = control || { cancelled: false, runner, statusTargets: new Set() }
|
|
1488
1592
|
const cycleStartedAt = performance.now()
|
|
1489
|
-
cycleControl.startedAt
|
|
1490
|
-
cycleControl.outcome = '
|
|
1593
|
+
cycleControl.startedAt ??= cycleStartedAt
|
|
1594
|
+
cycleControl.outcome = 'preflight'
|
|
1491
1595
|
emit('cycle.started', { cycleId: cycleControl.cycleId, kind, lane: laneName, status: 'preflight', model: kind === 'full' ? codeModel : liteModel, workdir: preparedWorkdir || (kind === 'full' ? workdir : ''), ...(taskRef ? { ticket: { projectId: taskRef.projectId, ticketId: taskRef.ticketId } } : {}), ...(delivery ? { thread: { channelId: delivery.channelId, threadId: delivery.parentId } } : {}), queueMs: Math.round(cycleStartedAt - (cycleControl.enqueuedAt ?? cycleStartedAt)) })
|
|
1492
1596
|
log(`cycle timing queue=${Math.round(cycleStartedAt - (cycleControl.enqueuedAt ?? cycleStartedAt))}ms kind=${kind}`)
|
|
1493
1597
|
const ctx = context ? [context] : []
|
|
@@ -1495,12 +1599,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1495
1599
|
let verifiedTaskRevision = ''
|
|
1496
1600
|
const ticketWorktree = preparedWorkdir || (kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : '')
|
|
1497
1601
|
const runnerOptions = {
|
|
1498
|
-
cycleId: cycleControl.cycleId, attempt: 1,
|
|
1602
|
+
cycleId: cycleControl.cycleId, attempt: cycleControl.attempt || 1,
|
|
1499
1603
|
...(delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {}),
|
|
1500
1604
|
...(ticketWorktree ? { workdir: ticketWorktree } : {}),
|
|
1501
1605
|
}
|
|
1502
1606
|
const targets = [...new Set(targetChannels.filter((id) => id != null && Number.isFinite(Number(id))).map(Number))]
|
|
1503
|
-
cycleControl.statusTargets =
|
|
1607
|
+
cycleControl.statusTargets = new Set(targets)
|
|
1504
1608
|
if (laneName === 'reply') {
|
|
1505
1609
|
replyStatusTargets.clear()
|
|
1506
1610
|
for (const target of targets) replyStatusTargets.add(target)
|
|
@@ -1523,8 +1627,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1523
1627
|
const cycleModelRevision = modelSettingsRevision
|
|
1524
1628
|
const useModel = kind === 'full' ? codeModel : liteModel
|
|
1525
1629
|
log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : '') + (useModel ? ' [' + useModel + ']' : ''))
|
|
1526
|
-
|
|
1527
|
-
// ticket transitions are the only user-visible progress signals.
|
|
1630
|
+
activity.set(cycleControl.cycleId, cycleControl.statusTargets, 'thinking')
|
|
1528
1631
|
try {
|
|
1529
1632
|
// The ticket may have been reassigned or handed to review while waiting.
|
|
1530
1633
|
// Re-check at dequeue, before any model can edit the repository.
|
|
@@ -1532,19 +1635,23 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1532
1635
|
const data = toolData(await callMcpTool('get_ticket', { project_id: activeTaskRef.projectId, ticket_id: activeTaskRef.ticketId }))
|
|
1533
1636
|
const ticket = data.ticket ?? data.task ?? data
|
|
1534
1637
|
verifiedTaskRevision = taskRevision(ticket)
|
|
1638
|
+
cycleControl.taskLabel = ticketDisplaySlug(ticket) || String(ticket.title || 'the task').slice(0, 160)
|
|
1535
1639
|
const typeSets = await taskTypeSets(activeTaskRef.projectId)
|
|
1536
1640
|
if (blockedTasks.has(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`) ||
|
|
1537
1641
|
!taskBelongsToAgent(ticket, { id: selfAgentId, identifier }) ||
|
|
1538
1642
|
taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
|
|
1539
1643
|
releaseTaskForRetry(activeTaskRef, prompt)
|
|
1644
|
+
cycleControl.outcome = 'skipped'
|
|
1540
1645
|
log('queued ticket no longer actionable; skipped before model start')
|
|
1541
1646
|
return
|
|
1542
1647
|
}
|
|
1543
1648
|
}
|
|
1544
1649
|
if (cycleControl.cancelled) return
|
|
1650
|
+
cycleControl.planKey ||= 'plan:' + (delivery?.key || `${activeTaskRef?.projectId}:${activeTaskRef?.ticketId}:${verifiedTaskRevision}`)
|
|
1545
1651
|
cycleControl.outcome = 'running'
|
|
1546
1652
|
log(`cycle timing preflight=${Math.round(performance.now() - cycleStartedAt)}ms kind=${kind}`)
|
|
1547
1653
|
const result = await runner.runCycle(prompt, useModel, runnerOptions)
|
|
1654
|
+
await cycleControl.planDelivery
|
|
1548
1655
|
cycleControl.outcome = result?.is_error ? 'error' : result?.subtype || 'error'
|
|
1549
1656
|
if (result?.timings) log(`cycle timing runtime=${JSON.stringify(result.timings)} kind=${kind}`)
|
|
1550
1657
|
refreshModelSettings()
|
|
@@ -1568,6 +1675,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1568
1675
|
return
|
|
1569
1676
|
}
|
|
1570
1677
|
if (!cycleSucceeded(result)) {
|
|
1678
|
+
if (kind === 'full' && interruptedRuntimeResult(result) && cycleControl.attempt <= recoveryDelays.length) return { ...result, transportInterrupted: true }
|
|
1571
1679
|
const outcome = result?.subtype || 'an unknown runtime error'
|
|
1572
1680
|
const notice = `I'm blocked because the ${kind === 'full' ? 'coding' : 'reply'} cycle ended with ${outcome}.${result?.subtype === 'rate_limited' ? ` The model provider is rate-limiting this request. Choose a model with available quota in Agent Studio, or retry once the limit resets.\n\n[Open Agent Studio](http://127.0.0.1:4317/#agent=${encodeURIComponent(identifier)}&settings=1)\n\n` : ' '}I'm not claiming completion.${activeTaskRef ? " I've paused this revision until the ticket changes." : ''}`
|
|
1573
1681
|
log('WORK_CYCLE_BLOCKED ' + outcome + '; publishing blocker')
|
|
@@ -1624,6 +1732,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1624
1732
|
}
|
|
1625
1733
|
}
|
|
1626
1734
|
} finally {
|
|
1735
|
+
activity.clear(cycleControl.cycleId)
|
|
1627
1736
|
cycleControl.statusTargets.clear()
|
|
1628
1737
|
}
|
|
1629
1738
|
}
|
|
@@ -1686,6 +1795,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1686
1795
|
for (const [name, queue] of Object.entries(queues)) {
|
|
1687
1796
|
queue.cancel((item) => String(item.taskRef?.projectId) === String(projectId) && String(item.taskRef?.ticketId) === String(ticketId), (item) => {
|
|
1688
1797
|
item.control.cancelled = true
|
|
1798
|
+
item.control.wakeRecovery?.()
|
|
1689
1799
|
void item.control.runner?.cancelCurrent?.()
|
|
1690
1800
|
})
|
|
1691
1801
|
}
|
|
@@ -1851,11 +1961,13 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1851
1961
|
}
|
|
1852
1962
|
if (cid != null && threadRoot != null) activateThread(cid, threadRoot, text)
|
|
1853
1963
|
memory.remember({ key: sourceKey, kind: 'mention', state: 'received', summary: text, refs: { channelId: cid, threadId: threadRoot, messageId: mid } })
|
|
1964
|
+
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 || '') } }) }
|
|
1965
|
+
catch { /* optional conversation indexing */ }
|
|
1854
1966
|
// Every runtime carries the source thread through the queue so a later
|
|
1855
|
-
// redirect can cancel its process.
|
|
1856
|
-
//
|
|
1967
|
+
// redirect can cancel its process. The watcher delivers the final answer
|
|
1968
|
+
// for every runtime after an authoritative last-moment thread check.
|
|
1857
1969
|
const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result') => cid != null
|
|
1858
|
-
? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned:
|
|
1970
|
+
? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: true }
|
|
1859
1971
|
: null
|
|
1860
1972
|
// Under-the-hood model control from chat (view / switch the model the agent runs).
|
|
1861
1973
|
const mcmd = cid != null ? parseModelCmd(text) : null
|
|
@@ -1940,7 +2052,19 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1940
2052
|
// recover channel mentions even when the backend omits targeted dispatch.
|
|
1941
2053
|
// Both event types share recipient, ownership and deduplication guards.
|
|
1942
2054
|
log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
|
|
1943
|
-
handle = (dependencies.connect || connectAgentWs)({ wsUrl, apiKey, identifier, onEvent,
|
|
2055
|
+
handle = (dependencies.connect || connectAgentWs)({ wsUrl, apiKey, identifier, onEvent,
|
|
2056
|
+
onDisconnect: () => {
|
|
2057
|
+
eventConnection = 'disconnected'
|
|
2058
|
+
emit('connection.disconnected', { status: 'disconnected', reason: 'Event connection lost; accepted work remains running' })
|
|
2059
|
+
},
|
|
2060
|
+
onConnect: () => {
|
|
2061
|
+
if (stopping) return
|
|
2062
|
+
eventConnection = 'connected'
|
|
2063
|
+
activity.pulse()
|
|
2064
|
+
emit('connection.connected', { status: 'connected' })
|
|
2065
|
+
for (const wake of [...recoveryWaiters]) wake()
|
|
2066
|
+
if (autoStart) setTimeout(() => { if (!stopping) void reconcileBacklog() }, 250)
|
|
2067
|
+
}, log })
|
|
1944
2068
|
|
|
1945
2069
|
let authorizationProbeBusy = false
|
|
1946
2070
|
const legacyAuthorizationChecks = new Set()
|
|
@@ -1981,7 +2105,14 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1981
2105
|
const modelSettingsTimer = autoStart ? setInterval(refreshModelSettings, 2_000) : null
|
|
1982
2106
|
modelSettingsTimer?.unref?.()
|
|
1983
2107
|
emit('models.updated', { model: codeModel, chatModel: liteModel, modelSettingsRevision })
|
|
1984
|
-
const heartbeatTimer = autoStart ? setInterval(() =>
|
|
2108
|
+
const heartbeatTimer = autoStart ? setInterval(() => {
|
|
2109
|
+
emit('watcher.heartbeat', { status: 'running', active: queues.work.activeSize + queues.reply.activeSize, pending: queues.work.size + queues.reply.size, model: codeModel, chatModel: liteModel, modelSettingsRevision, workdir: workdir || '' })
|
|
2110
|
+
// The Studio reads a bounded journal tail. Re-state current queue ownership
|
|
2111
|
+
// so a long-running cycle stays visible after its start event rotates out.
|
|
2112
|
+
for (const { data, control } of liveCycles.values()) {
|
|
2113
|
+
if (!control.cancelled) emit('cycle.snapshot', { ...data, status: control.outcome, attempt: control.attempt || 1 })
|
|
2114
|
+
}
|
|
2115
|
+
}, 15_000) : null
|
|
1985
2116
|
heartbeatTimer?.unref?.()
|
|
1986
2117
|
let introTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
|
|
1987
2118
|
if (autoStart && (mcpConfig || mcpUrl)) {
|
|
@@ -2021,12 +2152,14 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2021
2152
|
if (taskProbeTimer) clearInterval(taskProbeTimer)
|
|
2022
2153
|
try { handle?.close() } catch { /* already disconnected */ }
|
|
2023
2154
|
for (const queue of Object.values(queues)) {
|
|
2024
|
-
queue.cancel(() => true, (item) => { item.control.cancelled = true })
|
|
2155
|
+
queue.cancel(() => true, (item) => { item.control.cancelled = true; item.control.wakeRecovery?.() })
|
|
2025
2156
|
}
|
|
2026
2157
|
await Promise.allSettled([...activeReplyRunners, ...activeWorkRunners].map((runner) => Promise.resolve().then(() => runner.cancelCurrent?.())))
|
|
2027
2158
|
await Promise.allSettled([...mentionIntake.values(), ...Object.values(queues).map((queue) => queue.settled()), ...messageDeliveries.values(), ...pendingOperations])
|
|
2028
2159
|
workRunners.close()
|
|
2029
2160
|
replyRunners.close()
|
|
2161
|
+
await activity.close()
|
|
2162
|
+
try { await contextService.close() } catch (error) { log('context search shutdown failed: ' + (error?.message || error)) }
|
|
2030
2163
|
try { await memory.settled?.() }
|
|
2031
2164
|
catch (error) { log('memory shutdown failed: ' + (error?.message || error)) }
|
|
2032
2165
|
finally {
|
package/src/ws.mjs
CHANGED
|
@@ -37,7 +37,7 @@ export function assertWebSocket(fail) {
|
|
|
37
37
|
* @param {(kind: string, payload: any) => void} o.onEvent targeted dispatch.
|
|
38
38
|
* @param {(msg: string) => void} o.log
|
|
39
39
|
*/
|
|
40
|
-
export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect, log }) {
|
|
40
|
+
export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect, onDisconnect, log }) {
|
|
41
41
|
const base = stripSlash(wsUrl)
|
|
42
42
|
const url = `${base}?api_key=${encodeURIComponent(apiKey)}&identifier=${encodeURIComponent(identifier)}`
|
|
43
43
|
|
|
@@ -96,10 +96,11 @@ export function connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect,
|
|
|
96
96
|
})
|
|
97
97
|
|
|
98
98
|
const down = (why) => {
|
|
99
|
-
if (sock !== ws) return
|
|
99
|
+
if (closed || sock !== ws) return
|
|
100
100
|
clearKeepalive()
|
|
101
101
|
ws = null
|
|
102
102
|
log(why)
|
|
103
|
+
try { onDisconnect?.() } catch { /* connection telemetry is optional */ }
|
|
103
104
|
scheduleReconnect()
|
|
104
105
|
}
|
|
105
106
|
// $connect 401 (bad/inactive credentials) surfaces as an immediate close.
|
package/studio/app.mjs
CHANGED
|
@@ -82,7 +82,7 @@ export function deriveModel(input) {
|
|
|
82
82
|
if (event.type.startsWith('cycle.')) {
|
|
83
83
|
if (data.kind != null) cycle.data.kind = data.kind;
|
|
84
84
|
cycle.status = eventStatus(event);
|
|
85
|
-
if (event.type === 'cycle.started') cycle.startedAt
|
|
85
|
+
if (event.type === 'cycle.started') cycle.startedAt ||= event.timestamp;
|
|
86
86
|
if (terminalStatuses.has(cycle.status)) cycle.finishedAt = event.timestamp;
|
|
87
87
|
}
|
|
88
88
|
if (event.type.startsWith('tool.')) {
|
package/studio/guide.html
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
<a href="#start">Open Studio</a>
|
|
22
22
|
<a href="#watchers">Show your agents</a>
|
|
23
23
|
<a href="#find-work">Find and inspect work</a>
|
|
24
|
+
<a href="#recall">Tasks and conversation recall</a>
|
|
24
25
|
<a href="#statuses">Understand statuses</a>
|
|
25
26
|
<a href="#controls">Pause and follow</a>
|
|
26
27
|
<a href="#models">Change models</a>
|
|
@@ -71,6 +72,12 @@
|
|
|
71
72
|
<p><strong>No explicit plan recorded</strong> means the provider did not send a plan in the available history. A missing process ID means the provider did not report that operating-system identifier. Neither message alone means the work failed.</p>
|
|
72
73
|
<p>The process summary counts distinct reported process IDs associated with connected watchers. It is not a system-wide process monitor. The demo deliberately shows no real process count.</p>
|
|
73
74
|
</section>
|
|
75
|
+
<section id="recall">
|
|
76
|
+
<h2>Tasks and conversation recall</h2>
|
|
77
|
+
<p>Tell your agent “I have a task for you” or ask it to check its assignments. The agent has a live task lookup, a searchable tool guide, and short skills it can load when helpful. A lookup does not itself claim, start, or complete a task.</p>
|
|
78
|
+
<p>Ask about an earlier decision, such as “What did we decide about credential storage?” The agent can search recorded conversations by meaning and return to the original channel or thread for verification. It searches messages it has observed or fetched, so conversations it has never read may be missing.</p>
|
|
79
|
+
<p>The first semantic search downloads a local embedding model. Keyword search stays available while it loads. Later inference runs on this computer without an embedding API key. The agent chooses what context to retrieve and when to end its turn.</p>
|
|
80
|
+
</section>
|
|
74
81
|
<section id="statuses">
|
|
75
82
|
<h2>Understand statuses</h2>
|
|
76
83
|
<dl>
|
package/studio/style.css
CHANGED
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
button:disabled { cursor: default; opacity: .6; }
|
|
46
46
|
button, a, input, select, summary { touch-action: manipulation; }
|
|
47
47
|
:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
|
|
48
|
+
input:focus, select:focus, textarea:focus { outline: none; }
|
|
49
|
+
input:focus-visible, select:focus-visible, textarea:focus-visible { box-shadow: inset 0 -2px 0 var(--focus); }
|
|
48
50
|
button, select, a { transition: background-color .16s ease-out, color .16s ease-out, box-shadow .16s ease-out; }
|
|
49
51
|
h1, h2, h3, h4, p { margin: 0; }
|
|
50
52
|
h1, h2, h3, h4, strong { font-weight: 500; }
|
|
@@ -76,7 +78,7 @@
|
|
|
76
78
|
.count { font-size: 12px; background: var(--stage); min-width: 22px; height: 19px; display: grid; place-items: center; border-radius: 999px; font-variant-numeric: tabular-nums; }
|
|
77
79
|
.agent-search { display: flex; align-items: center; gap: 7px; padding: 8px 10px; margin: 0 0 12px; color: var(--subtle); }
|
|
78
80
|
.agent-search .icon { width: 14px; height: 14px; }
|
|
79
|
-
.agent-search input { width: 100%; border: 0;
|
|
81
|
+
.agent-search input { width: 100%; border: 0; background: transparent; font-size: 12px; }
|
|
80
82
|
.agent-list { display: flex; flex-direction: column; gap: 5px; }
|
|
81
83
|
.agent-item { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; border: 0; background: transparent; padding: 10px; border-radius: var(--radius-md); corner-shape: squircle; }
|
|
82
84
|
.agent-item:hover { background: var(--hover); }
|
|
@@ -123,7 +125,7 @@
|
|
|
123
125
|
.filters { display: flex; gap: 9px; margin-bottom: 19px; }
|
|
124
126
|
.search-field { flex: 1; min-width: 90px; display: flex; align-items: center; gap: 9px; border: 1px solid var(--border); border-radius: 999px; padding: 8px 12px; color: var(--subtle); }
|
|
125
127
|
.search-field .icon { width: 14px; height: 14px; }
|
|
126
|
-
.search-field input { width: 100%; background: transparent; border: 0; font-size: 12px;
|
|
128
|
+
.search-field input { width: 100%; background: transparent; border: 0; font-size: 12px; }
|
|
127
129
|
.select-field { display: inline-flex; align-items: center; min-width: 108px; }
|
|
128
130
|
.select-field select { width: 100%; min-height: 40px; padding: 7px 26px 7px 12px; border: 1px solid var(--border); border-radius: 999px; background-color: var(--surface); font-size: 12px; }
|
|
129
131
|
.content-grid { display: grid; grid-template-columns: minmax(280px, .85fr) minmax(360px, 1.15fr); gap: 14px; align-items: start; }
|
|
@@ -360,7 +362,6 @@
|
|
|
360
362
|
.model-settings-form label { display: grid; gap: 4px; font-size: 13px; margin-top: 8px; }
|
|
361
363
|
.model-settings-form label span { font-size: 12px; color: var(--muted); }
|
|
362
364
|
.model-settings-form input { width: 100%; min-width: 0; padding: 11px 14px; border: 1px solid var(--border); border-radius: 18px; background: transparent; color: var(--ink); font-size: 13px; }
|
|
363
|
-
.model-settings-form input:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
364
365
|
.model-settings-actions { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; margin-top: 8px; }
|
|
365
366
|
#model-settings-error { color: #9f3030; }
|
|
366
367
|
@media (max-width: 640px) {
|