openvisio-agent 0.25.10 → 0.25.11

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.25.11] — 2026-09-21
4
+
5
+ - Explain and enforce task ownership identity: `assignee_id` is for human members, while `agent_id` is for registered agents, and task mutations cannot send both.
6
+ - Keep agent messages, task comments, titles, and descriptions within readable limits at the shared MCP bridge; watcher-owned oversized replies are shortened while full output remains in Agent Studio.
7
+ - Stop automated peer exchanges after one agent reply until a human speaks, suppress sender-less echoes of watcher comments, and ignore explicitly self-authored task status events before they start another cycle.
8
+
3
9
  ## [0.25.10] — 2026-09-21
4
10
 
5
11
  - Send `thinking`, `working`, and `typing` activity through the authenticated agent WebSocket, with the REST endpoint retained only while the socket is reconnecting.
package/README.md CHANGED
@@ -87,7 +87,7 @@ The backend MCP may be stateful or stateless. A successful initialize response w
87
87
 
88
88
  Codex BYO agents follow the repository's normative runtime specification in `docs/CODEX_BYO_AGENT_SPEC.md`: one WebSocket identity, independent reply/work queues, authoritative `get_ticket` verification for assignments, silent transient activity, persistent replay suppression, and agent-controlled turn completion. Maintainers must run `npm run certify` before publishing.
89
89
 
90
- Both `agent:mention` and organization `channel:message` events pass through the same source-recipient, ownership, and deduplication checks before any model starts. The watcher hydrates compact roster entries through `get_agent`, subscribes only to the organization verified from that authenticated profile, and restores the subscription after reconnect. Organization `task:created` and `task:updated` events enter the same authoritative assignment verifier as targeted `task:assigned` events, so a task starts only after `get_ticket` confirms it is open and owned by this agent. Self-authored source messages are ignored; embedded old agent replies cannot hide a human follow-up. An explicit tag establishes durable ownership of that thread, so later human follow-ups remain addressed to the agent without another @mention. Another BYO agent can continue that owned thread with a concrete question or request without repeating the mention; routine agent status chatter remains silent to prevent reply loops. Ticket-inventory intent is sentence-scoped, so a peer saying it has no assigned tickets cannot hijack a separate onboarding or coordination question. Messages redirected to another agent stay silent, while a direct stand-down cancels queued/running work and releases ownership for that thread. Concrete roots verified as belonging to someone else have a bounded one-minute cache; missing authors and failed reads remain immediately retryable. The watcher publishes working, thinking, and typing presence from actual runtime activity using the authenticated REST endpoint. It stops refreshing when work finishes or is cancelled; the app clears the indicator through its expiry timer. Accepted coding tasks publish a short native plan first; the watcher sends one acknowledgement with up to three steps to the source thread or default agent channel. Every runtime returns a distinct final answer for guarded watcher delivery. Plans and reconnects cannot duplicate or suppress that final answer.
90
+ Both `agent:mention` and organization `channel:message` events pass through the same source-recipient, ownership, and deduplication checks before any model starts. The watcher hydrates compact roster entries through `get_agent`, subscribes only to the organization verified from that authenticated profile, and restores the subscription after reconnect. Organization `task:created` and `task:updated` events enter the same authoritative assignment verifier as targeted `task:assigned` events, so a task starts only after `get_ticket` confirms it is open and owned by this agent. Explicit self-authored task updates and source messages are ignored; embedded old agent replies cannot hide a human follow-up. An explicit tag establishes durable ownership of that thread, so later untagged human follow-ups remain addressed to the agent. Another BYO agent can make one concrete request after the latest human turn; after this agent answers, further peer turns stay silent until a human speaks. Routine agent chatter, reciprocal questions, and sender-less echoes of watcher-delivered task comments remain silent. Ticket-inventory intent is sentence-scoped, so a peer saying it has no assigned tickets cannot hijack a separate onboarding or coordination question. Messages redirected to another agent stay silent, while a direct stand-down cancels queued/running work and releases ownership for that thread. Agent messages and comments are kept within 1,200 characters, and task mutations expose the distinction between human `assignee_id` and agent `agent_id`. Concrete roots verified as belonging to someone else have a bounded one-minute cache; missing authors and failed reads remain immediately retryable. The watcher publishes working, thinking, and typing presence from actual runtime activity using the authenticated REST endpoint. It stops refreshing when work finishes or is cancelled; the app clears the indicator through its expiry timer. Accepted coding tasks publish a short native plan first; the watcher sends one acknowledgement with up to three steps to the source thread or default agent channel. Every runtime returns a distinct final answer for guarded watcher delivery. Plans and reconnects cannot duplicate or suppress that final answer.
91
91
 
92
92
  Ticket references follow the board UI: BYO agents use the project-scoped slug, such as `OVS-57`, in messages, comments, PR descriptions, blockers, and results. Numeric `project_id` and `ticket_id` values remain internal MCP arguments and are never used as human-facing ticket names. If an older backend omits the slug, the agent uses the ticket title rather than inventing one.
93
93
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.25.10",
3
+ "version": "0.25.11",
4
4
  "description": "Connect Claude Code, Codex, OpenCode, Gemini CLI, or Qwen Code to an OpenVisio team \u2014 MCP tools + optional autonomy \u2014 in one command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,6 +30,7 @@ const mcpHttp = readFileSync(join(root, 'src', 'mcp-http.mjs'), 'utf8')
30
30
  const opencodeConfig = readFileSync(join(root, 'src', 'opencode-config.mjs'), 'utf8')
31
31
  const codexConfig = readFileSync(join(root, 'src', 'codex-config.mjs'), 'utf8')
32
32
  const codexProxy = readFileSync(join(root, 'src', 'codex-mcp-proxy.mjs'), 'utf8')
33
+ const toolPolicy = readFileSync(join(root, 'src', 'tool-policy.mjs'), 'utf8')
33
34
  const prPush = readFileSync(join(root, 'src', 'pr-push.mjs'), 'utf8')
34
35
  const cli = readFileSync(join(root, 'bin', 'cli.mjs'), 'utf8')
35
36
  const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
@@ -59,6 +60,9 @@ const assertions = [
59
60
  ['connection recovery scans assignments without an activity-feed pass', watcher.includes('const reconcileBacklog') && watcher.includes("await handleTaskSignal('task:assigned'") && !watcher.includes('reconcileActivity')],
60
61
  ['task:assigned is handled', watcher.includes("k === 'task:assigned'")],
61
62
  ['task creation and update fanout use the assignment verifier', watcher.includes("k === 'task:created'") && watcher.includes("k === 'task:updated'") && watcher.includes('return handleTaskSignal(k, raw)')],
63
+ ['task ownership distinguishes human assignees from registered agents', toolPolicy.includes('assignee_id is only for a human user/member id') && toolPolicy.includes('agent_id is only for a registered coding agent id') && codexProxy.includes('validateAgentToolArguments')],
64
+ ['agent-authored task updates stop before ticket verification', watcher.includes("kind === 'task:updated'") && watcher.includes('taskEventSenderIsSelf(raw') && events.includes('export function taskEventSenderIsSelf')],
65
+ ['agent communication has shared readable limits', toolPolicy.includes('post_message:') && toolPolicy.includes('max: 1200') && toolPolicy.includes('compactAgentMessage') && watcher.includes('compactAgentMessage(content, { maxChars: 1200 })')],
62
66
  ['compact agent records are hydrated before organization subscription', watcher.includes("callMcpTool('get_agent', { agent_id: selfAgentId })") && watcher.includes('selfAgentId != null && selfOrganizationId == null')],
63
67
  ['task signals are verified with get_ticket', watcher.includes("callMcpTool('get_ticket'")],
64
68
  ['review and testing handoffs do not restart work', watcher.includes('taskIsAwaitingReview(task, reviewIds)') && watcher.includes('taskIsAwaitingReview(ticket, typeSets.review)')],
@@ -67,13 +71,14 @@ const assertions = [
67
71
  ['agent results are delivered independently of ticket completion', watcher.includes('agentFinal: true') && watcher.includes('pendingAgentResults')],
68
72
  ['completion delivery survives reconnect and deduplicates', watcher.includes('pendingCompletionReports: [...pendingCompletionReports]') && watcher.includes('reportedCompletions: [...reportedCompletions]') && watcher.includes('reportedCompletions.has(report.key)')],
69
73
  ['optional ticket comments cannot block verified completion', watcher.includes('callOptionalTicketComment({ projectId, ticketId, content: report.content })') && watcher.includes('no ticket comment tool is exposed; completing ticket') && watcher.includes('reportedTaskComments: [...reportedTaskComments]') && watcher.includes('reportedTaskComments.has(report.key)')],
70
- ['ticket comments prefer the advertised modern schema with legacy compatibility', watcher.includes("names.has('create_task_comment') ? 'create_task_comment' : names.has('comment_ticket') ? 'comment_ticket' : ''") && watcher.includes("name === 'create_task_comment'") && watcher.includes("{ parent_id: Number(parentId) }") && watcher.includes("{ text: content }")],
74
+ ['ticket comments prefer the advertised modern schema with legacy compatibility', watcher.includes("names.has('create_task_comment') ? 'create_task_comment' : names.has('comment_ticket') ? 'comment_ticket' : ''") && watcher.includes("name === 'create_task_comment'") && watcher.includes("{ parent_id: Number(parentId) }") && watcher.includes("{ content: message") && watcher.includes("{ text: message }")],
71
75
  ['uncertain comment writes never trigger duplicate alternate ticket writes', watcher.includes('never try another comment endpoint after an error') && watcher.includes('no alternate ticket write will be attempted') && !watcher.includes("reason: 'not-available'")],
72
76
  ['ticket comments cannot masquerade as channel completion', events.includes("didChannelMessage: completed && mcpTool === 'post_message'") && watcher.includes('result.didChannelMessage')],
73
77
  ['single-watcher acquisition is atomic and fails closed', watcher.includes("openSync(lockPath, 'wx')") && watcher.includes('Could not acquire the single-watcher lock')],
74
78
  ['websocket mention delivery uses a persistent replay guard', watcher.includes('markMentionHandled(msg, cid)') && watcher.includes('recentMentionSignatures')],
75
79
  ['conversation wake events preserve owned-thread follow-ups and filter other recipients before model start', watcher.includes("memory.has(controlKey, 'active')") && watcher.includes('classifyConversationTarget(msg, [...selfAliases], { threadOwned })') && events.includes("'owned-thread-follow-up'") && events.includes("reason: 'explicit-other-recipient'") && events.includes("reason: 'other-agent-chatter'")],
76
80
  ['owned threads admit concrete peer requests without opening agent chatter loops', events.includes("reason: peerFollowUp ? 'owned-peer-follow-up'") && watcher.includes('This request was authored by another team agent') && watcher.includes('do not append a reciprocal question')],
81
+ ['peer-agent exchanges stop after one reply until a human turn', watcher.includes("state: 'replied'") && watcher.includes("state: 'human'") && watcher.includes('peer-loop-budget') && watcher.includes('ignored-peer-loop')],
77
82
  ['pending-ticket intent is scoped to one clause', events.includes("text.split(/(?<=[.!?])") && events.includes('return tasks && pending && question')],
78
83
  ['prematurely acknowledged owned-thread replies recover exactly once', watcher.includes("!threadOwned || memory.has(sourceKey, 'received')") && watcher.includes('agent:mention replay recovered for active owned thread')],
79
84
  ['task comments use assigned-ticket verification and watcher-owned threaded delivery', watcher.includes("k === 'task:comment'") && watcher.includes('taskCommentFromEvent(raw)') && watcher.includes('postTaskCommentOnce') && watcher.includes("surface: 'task-comment'") && watcher.includes("['post_message', 'create_task_comment', 'comment_ticket']")],
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'
4
4
  import { WORK_SESSION_TOOL, runtimeControlTools, requestWorkSession } from './runtime-control.mjs'
5
5
  import { createMcpHttpClient } from './mcp-http.mjs'
6
6
  import { contextTools, contextToolNames, callContextService, validateContextArguments } from './context-tools.mjs'
7
+ import { validateAgentToolArguments, withAgentToolPolicy } from './tool-policy.mjs'
7
8
 
8
9
  export function toolWithoutCredentialInputs(tool) {
9
10
  const copy = structuredClone(tool)
@@ -17,7 +18,7 @@ export function toolWithoutCredentialInputs(tool) {
17
18
  schema.required = schema.required.filter((name) => name !== 'agent_api_key' && name !== 'agent_identifier')
18
19
  }
19
20
  }
20
- return copy
21
+ return withAgentToolPolicy(copy)
21
22
  }
22
23
 
23
24
  export function toolAllowed(name, { disabled = new Set(), allowed = null } = {}) {
@@ -146,16 +147,17 @@ export function runCodexMcpProxy() {
146
147
  }
147
148
  reply(id, { content: [{ type: 'text', text: JSON.stringify(data) }] }); return
148
149
  }
150
+ const toolArguments = validateAgentToolArguments(name, request.params?.arguments || {})
149
151
  let result
150
- try { result = await client.callTool(name, request.params?.arguments || {}) }
152
+ try { result = await client.callTool(name, toolArguments) }
151
153
  catch (error) {
152
154
  // An allowlisted coordination mutation still must never be replayed
153
155
  // after an ambiguous timeout. Retry only idempotent reads.
154
156
  if (!(allowedTools instanceof Set) || !allowedTools.has(name) || !/^(?:list_|get_|codebase_tree$)/.test(name)) throw error
155
- result = await client.callTool(name, request.params?.arguments || {})
157
+ result = await client.callTool(name, toolArguments)
156
158
  }
157
159
  if (localContext.length && ['list_message_thread', 'post_message'].includes(name) && !result?.isError) {
158
- try { await callContextService(contextEnvironment, { action: 'observe', name, args: request.params?.arguments || {}, result }, 1000) }
160
+ try { await callContextService(contextEnvironment, { action: 'observe', name, args: toolArguments, result }, 1000) }
159
161
  catch { /* optional indexing cannot fail the successful backend action */ }
160
162
  }
161
163
  reply(id, /^(?:list_agents|get_agent|get_agent_details)$/.test(name) ? agentResultWithoutCredentials(result) : result)
package/src/events.mjs CHANGED
@@ -55,6 +55,25 @@ export function taskBelongsToAgent(task, { id, identifier } = {}) {
55
55
  return !!identifier && assignedIdentifier != null && String(assignedIdentifier) === String(identifier)
56
56
  }
57
57
 
58
+ // A task's `agent_id` is its owner, not the author of a task update. Only
59
+ // explicit actor fields can prove that an incoming task event came from this
60
+ // agent. This distinction prevents an assigned agent from treating every task
61
+ // update as self-authored while still suppressing its own status mutations.
62
+ export function taskEventSenderIsSelf(payload, identity = {}) {
63
+ if (!payload || typeof payload !== 'object') return false
64
+ const envelopes = [payload, payload.data, payload.payload, payload.task, payload.data?.task, payload.payload?.task]
65
+ .filter((value) => value && typeof value === 'object' && !Array.isArray(value))
66
+ for (const source of envelopes) {
67
+ const actor = [source.actor_agent, source.actorAgent, source.updated_by_agent, source.updatedByAgent, source.changed_by_agent, source.changedByAgent, source.sender_agent, source.senderAgent, source.author_agent, source.authorAgent]
68
+ .find((value) => value && typeof value === 'object' && !Array.isArray(value))
69
+ const actorId = source.actor_agent_id ?? source.actorAgentId ?? source.updated_by_agent_id ?? source.updatedByAgentId ?? source.changed_by_agent_id ?? source.changedByAgentId ?? source.sender_agent_id ?? source.senderAgentId ?? source.author_agent_id ?? source.authorAgentId
70
+ const actorIdentifier = source.actor_agent_identifier ?? source.actorAgentIdentifier ?? source.updated_by_agent_identifier ?? source.updatedByAgentIdentifier ?? source.sender_agent_identifier ?? source.senderAgentIdentifier ?? source.author_agent_identifier ?? source.authorAgentIdentifier
71
+ if (!actor && actorId == null && actorIdentifier == null) continue
72
+ if (messageSenderIsSelf({ senderAgent: actor || { id: actorId, identifier: actorIdentifier }, sender_agent_id: actorId, sender_identifier: actorIdentifier }, identity)) return true
73
+ }
74
+ return false
75
+ }
76
+
58
77
  // Backend task ids are database plumbing. Teammates identify tickets by the
59
78
  // project-scoped slug shown on the board (for example, OVS-57). Never synthesize
60
79
  // a slug from the numeric id: if an older backend omits it, human-facing copy
@@ -415,12 +434,12 @@ export function normalizeRenderedMessageText(value) {
415
434
  export function messageSenderIsSelf(row, identity = {}) {
416
435
  if (!row || typeof row !== 'object') return false
417
436
  const sender = [row.taskSender, row.task_sender, row.sender, row.user, row.author, row.member].find((item) => item && typeof item === 'object') || {}
418
- const expanded = [row.taskSenderAgent, row.task_sender_agent, row.senderAgent, row.sender_agent, row.agent, sender.agent].find((item) => item && typeof item === 'object')
437
+ const expanded = [row.taskSenderAgent, row.task_sender_agent, row.senderAgent, row.sender_agent, row.authorAgent, row.author_agent, row.actorAgent, row.actor_agent, row.createdByAgent, row.created_by_agent, row.agent, sender.agent].find((item) => item && typeof item === 'object')
419
438
  const senderKind = String(sender.type ?? sender.kind ?? sender.sender_type ?? row.sender_type ?? row.author_type ?? '').toLowerCase()
420
439
  const senderLooksLikeAgent = agentSenderKind(senderKind) || (!humanSenderKind(senderKind) && (sender.agent_id != null || sender.identifier != null || (sender.slug != null && !sender.email)))
421
440
  const agent = expanded || (senderLooksLikeAgent ? sender : {})
422
- const agentId = agent.id ?? agent.agent_id ?? row.agent_id ?? row.sender_agent_id ?? row.senderAgentId
423
- const identifier = String(agent.identifier ?? agent.slug ?? row.agent_identifier ?? row.sender_identifier ?? '')
441
+ const agentId = agent.id ?? agent.agent_id ?? row.agent_id ?? row.sender_agent_id ?? row.senderAgentId ?? row.task_sender_agent_id ?? row.taskSenderAgentId ?? row.author_agent_id ?? row.authorAgentId ?? row.actor_agent_id ?? row.actorAgentId ?? row.created_by_agent_id ?? row.createdByAgentId
442
+ const identifier = String(agent.identifier ?? agent.slug ?? row.agent_identifier ?? row.sender_identifier ?? row.task_sender_agent_identifier ?? row.taskSenderAgentIdentifier ?? row.author_agent_identifier ?? row.authorAgentIdentifier ?? row.actor_agent_identifier ?? row.actorAgentIdentifier ?? '')
424
443
  const name = String(agent.name ?? agent.display_name ?? (senderLooksLikeAgent ? sender.name : '') ?? '')
425
444
  const aliases = new Set([identity.identifier, identity.slug, identity.name].map((item) => String(item || '').toLowerCase()).filter(Boolean))
426
445
  const stableAliases = new Set([identity.identifier, identity.slug].map((item) => String(item || '').toLowerCase()).filter(Boolean))
@@ -501,7 +520,7 @@ function conversationMentions(value, selfAliases) {
501
520
 
502
521
  export function messageSenderIsAgent(message) {
503
522
  const m = message && typeof message === 'object' ? message : {}
504
- if (m.taskSenderAgent || m.task_sender_agent || m.senderAgent || m.sender_agent || m.agent || m.agent_id != null || m.sender_agent_id != null || m.senderAgentId != null) return true
523
+ if (m.taskSenderAgent || m.task_sender_agent || m.senderAgent || m.sender_agent || m.authorAgent || m.author_agent || m.actorAgent || m.actor_agent || m.createdByAgent || m.created_by_agent || m.agent || m.agent_id != null || m.sender_agent_id != null || m.senderAgentId != null || m.task_sender_agent_id != null || m.taskSenderAgentId != null || m.task_sender_agent_identifier != null || m.taskSenderAgentIdentifier != null || m.author_agent_id != null || m.authorAgentId != null || m.actor_agent_id != null || m.actorAgentId != null || m.created_by_agent_id != null || m.createdByAgentId != null) return true
505
524
  const sender = [m.taskSender, m.task_sender, m.sender, m.user, m.author, m.member].find((item) => item && typeof item === 'object') || {}
506
525
  if (sender.agent && typeof sender.agent === 'object') return true
507
526
  const kind = String(sender.type ?? sender.kind ?? sender.sender_type ?? m.sender_type ?? m.author_type ?? '').toLowerCase()
@@ -0,0 +1,100 @@
1
+ const TASK_MUTATION_TOOLS = new Set(['create_ticket', 'update_ticket', 'create_task', 'update_task'])
2
+
3
+ const TEXT_LIMITS = {
4
+ post_message: { fields: ['content', 'text', 'message', 'body'], max: 1200, label: 'Channel messages' },
5
+ create_task_comment: { fields: ['content', 'text', 'message', 'body'], max: 1200, label: 'Task comments' },
6
+ comment_ticket: { fields: ['content', 'text', 'message', 'body'], max: 1200, label: 'Task comments' },
7
+ create_ticket: { fields: ['description'], max: 2400, label: 'Task descriptions' },
8
+ update_ticket: { fields: ['description'], max: 2400, label: 'Task descriptions' },
9
+ create_task: { fields: ['description'], max: 2400, label: 'Task descriptions' },
10
+ update_task: { fields: ['description'], max: 2400, label: 'Task descriptions' },
11
+ }
12
+
13
+ const OWNERSHIP_GUIDANCE = 'Task ownership fields are different: assignee_id is only for a human user/member id; agent_id is only for a registered coding agent id. Never put an agent id in assignee_id. Set at most one of these fields and clear the other when changing owner type.'
14
+ const CONCISE_GUIDANCE = 'Keep this text compact and easy to scan. Lead with the result or request, omit repeated context, and avoid open-ended invitations.'
15
+
16
+ function appendDescription(value, guidance) {
17
+ const current = String(value || '').trim()
18
+ return current.includes(guidance) ? current : [current, guidance].filter(Boolean).join(' ')
19
+ }
20
+
21
+ function stringBranches(schema) {
22
+ if (!schema || typeof schema !== 'object') return []
23
+ return [schema, ...(Array.isArray(schema.anyOf) ? schema.anyOf : []), ...(Array.isArray(schema.oneOf) ? schema.oneOf : [])]
24
+ .filter((item) => item && typeof item === 'object' && (item.type === 'string' || !item.type))
25
+ }
26
+
27
+ function setStringLimit(schema, max, guidance = CONCISE_GUIDANCE) {
28
+ if (!schema || typeof schema !== 'object') return
29
+ schema.description = appendDescription(schema.description, `${guidance} Maximum ${max} characters.`)
30
+ for (const branch of stringBranches(schema)) {
31
+ if (branch.type === 'string') branch.maxLength = Math.min(Number.isFinite(branch.maxLength) ? branch.maxLength : max, max)
32
+ }
33
+ }
34
+
35
+ export function withAgentToolPolicy(tool) {
36
+ const copy = structuredClone(tool)
37
+ const name = String(copy?.name || '')
38
+ const schemas = [copy.inputSchema, copy.input_schema].filter((schema) => schema && typeof schema === 'object')
39
+
40
+ if (TASK_MUTATION_TOOLS.has(name)) {
41
+ copy.description = appendDescription(copy.description, OWNERSHIP_GUIDANCE)
42
+ for (const schema of schemas) {
43
+ const properties = schema.properties && typeof schema.properties === 'object' ? schema.properties : {}
44
+ if (properties.assignee_id) properties.assignee_id.description = appendDescription(properties.assignee_id.description, 'Human user/member id only. Never use an agent id here.')
45
+ if (properties.agent_id) properties.agent_id.description = appendDescription(properties.agent_id.description, 'Registered agent id only. Never use a human user id here.')
46
+ }
47
+ }
48
+
49
+ const limit = TEXT_LIMITS[name]
50
+ if (limit) {
51
+ copy.description = appendDescription(copy.description, `${limit.label} must be concise. Maximum ${limit.max} characters.`)
52
+ for (const schema of schemas) {
53
+ const properties = schema.properties && typeof schema.properties === 'object' ? schema.properties : {}
54
+ for (const field of limit.fields) if (properties[field]) setStringLimit(properties[field], limit.max)
55
+ if (TASK_MUTATION_TOOLS.has(name) && properties.title) setStringLimit(properties.title, 160, 'Use a short, specific title.')
56
+ }
57
+ }
58
+ return copy
59
+ }
60
+
61
+ const present = (value) => value !== undefined && value !== null && value !== ''
62
+
63
+ export function validateAgentToolArguments(name, args = {}) {
64
+ const tool = String(name || '')
65
+ const input = args && typeof args === 'object' && !Array.isArray(args) ? args : {}
66
+ if (TASK_MUTATION_TOOLS.has(tool) && present(input.assignee_id) && present(input.agent_id)) {
67
+ throw new Error(`${OWNERSHIP_GUIDANCE} This call supplied both fields; choose the intended owner and retry once.`)
68
+ }
69
+ const limit = TEXT_LIMITS[tool]
70
+ if (limit) {
71
+ for (const field of limit.fields) {
72
+ if (!present(input[field])) continue
73
+ const length = String(input[field]).length
74
+ if (length > limit.max) throw new Error(`${limit.label} must be ${limit.max} characters or fewer; ${field} was ${length}. Rewrite it as a short result, request, or decision and retry once.`)
75
+ }
76
+ }
77
+ if (TASK_MUTATION_TOOLS.has(tool) && present(input.title) && String(input.title).length > 160) {
78
+ throw new Error(`Task titles must be 160 characters or fewer; title was ${String(input.title).length}. Shorten it and retry once.`)
79
+ }
80
+ return input
81
+ }
82
+
83
+ export function compactAgentMessage(value, { maxChars = 1200 } = {}) {
84
+ const text = String(value || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
85
+ if (!text || text.length <= maxChars) return text
86
+ const suffix = '\n\n[Shortened for readability. Full output remains in Agent Studio.]'
87
+ const budget = Math.max(1, maxChars - suffix.length)
88
+ const candidate = text.slice(0, budget)
89
+ const minimum = Math.floor(budget * 0.65)
90
+ const stops = [candidate.lastIndexOf('\n\n'), candidate.lastIndexOf('. '), candidate.lastIndexOf('! '), candidate.lastIndexOf('? '), candidate.lastIndexOf(' ')]
91
+ const stop = stops.find((index) => index >= minimum)
92
+ return `${candidate.slice(0, stop >= minimum ? stop + (candidate[stop] === '.' || candidate[stop] === '!' || candidate[stop] === '?' ? 1 : 0) : budget).trimEnd()}${suffix}`
93
+ }
94
+
95
+ export const agentToolPolicy = Object.freeze({
96
+ ownershipGuidance: OWNERSHIP_GUIDANCE,
97
+ channelMessageMaxChars: TEXT_LIMITS.post_message.max,
98
+ taskCommentMaxChars: TEXT_LIMITS.create_task_comment.max,
99
+ taskDescriptionMaxChars: TEXT_LIMITS.create_ticket.max,
100
+ })
package/src/watch.mjs CHANGED
@@ -10,12 +10,13 @@ import { fileURLToPath } from 'node:url'
10
10
  import { join, dirname } from 'node:path'
11
11
  import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
12
12
  import { connectAgentWs, assertWebSocket } from './ws.mjs'
13
- import { buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, conversationAsksPendingTickets, conversationCreatesTicket, conversationNeedsCode, conversationRequestsChannelMessage, failedTaskRevisionIsCurrent, mentionDedupeKeys, messageParentId, messageSenderIsAgent, messageSenderIsSelf, normalizeRenderedMessageText, renderedAgentMessages, taskAgentId, taskAgentIdentifier, taskBelongsToAgent, taskCommentFromEvent, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
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
16
  import { createContextService } from './context-service.mjs'
17
17
  import { contextToolGuide } from './context-tools.mjs'
18
18
  import { toolWithoutCredentialInputs } from './codex-mcp-proxy.mjs'
19
+ import { compactAgentMessage } from './tool-policy.mjs'
19
20
  import { repositoryHasPrPushAuthorization, configurePrPublishing } from './pr-push.mjs'
20
21
  import { createMcpHttpClient } from './mcp-http.mjs'
21
22
  import { createCycleQueue } from './cycle-queue.mjs'
@@ -85,9 +86,12 @@ const REPLY_DISCIPLINE = [
85
86
  ' • 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.',
86
87
  ' • 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.',
87
88
  ' • 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.',
89
+ ' • ASSIGNEE IDS ARE HUMAN IDS. On task mutations, assignee_id means a human user/member. agent_id means a registered agent. Never put an agent id in assignee_id, never put a human id in agent_id, and never send both. To assign work to yourself or another agent, resolve the registered agent and use agent_id.',
88
90
  ' • TICKET SLUGS, NEVER DATABASE IDS. In every human-facing channel message, ticket comment, PR description, summary, blocker, and result, reference a ticket by the exact project-scoped slug returned by get_ticket/list_tasks (for example, `OVS-57`). Numeric project_id and ticket_id values are internal MCP arguments only: never write `#57`, `ticket 57`, or expose a database id to teammates. If the backend omits the slug, use the ticket title or say “the ticket”; do not invent a slug.',
89
91
  ' • CLOSE CONCERNS. Never leave a concern, direct question, correction, or blocker addressed to you without a clear response. Acknowledge the concern, act if you can, then report the verified result. If blocked, name the blocker and the exact next action or owner in one message.',
90
92
  ' • HONOR CHANNEL DESTINATIONS. Reply to an existing request in its original thread. When a teammate explicitly asks you to send, post, announce, or share something in another channel, resolve that exact destination from live list_channels and post there. Use your dedicated agent channel for fresh top-level results, blockers, and introductions only when no other destination was requested. Never invent a channel id or silently substitute a different channel.',
93
+ ' • KEEP MESSAGES COMPACT. Lead with the answer, result, decision, or blocker. Default to 1-4 short sentences or at most 6 brief bullets, normally under 600 characters and never over 1,200. Do not restate the prompt, narrate routine tool use, dump raw logs, or repeat context already visible in the thread. Task descriptions may use up to 2,400 characters when acceptance details require it.',
94
+ ' • END AGENT-TO-AGENT TURNS. Answer one concrete peer-agent request once, without a reciprocal question, invitation, thank-you exchange, or status echo. Then wait for a human or a genuinely new human-directed request. Do not keep a conversation alive merely because another agent replied.',
91
95
  ' • WRITE PLAINLY. Prefer short sentences, commas, periods, and colons. Avoid em dashes except when reproducing quoted text.',
92
96
  ].join('\n')
93
97
 
@@ -1015,14 +1019,19 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1015
1019
  // reached the server, so never try another comment endpoint after an error.
1016
1020
  const name = names.has('create_task_comment') ? 'create_task_comment' : names.has('comment_ticket') ? 'comment_ticket' : ''
1017
1021
  if (!name) return { called: false, reason: 'not-advertised' }
1022
+ const message = compactAgentMessage(content, { maxChars: 1200 })
1018
1023
  const args = {
1019
1024
  project_id: projectId,
1020
1025
  ticket_id: ticketId,
1021
1026
  ...(name === 'create_task_comment'
1022
- ? { content, ...(validBackendId(parentId) ? { parent_id: Number(parentId) } : {}) }
1023
- : { text: content }),
1027
+ ? { content: message, ...(validBackendId(parentId) ? { parent_id: Number(parentId) } : {}) }
1028
+ : { text: message }),
1029
+ }
1030
+ try {
1031
+ const result = await callMcpTool(name, args)
1032
+ memory.remember({ key: ownTaskCommentKey(projectId, ticketId, message), kind: 'task-comment', state: 'rendered', summary: message, refs: { projectId: Number(projectId), ticketId: Number(ticketId), ...(validBackendId(parentId) ? { threadId: Number(parentId) } : {}) } })
1033
+ return { called: true, tool: name, result }
1024
1034
  }
1025
- try { return { called: true, tool: name, result: await callMcpTool(name, args) } }
1026
1035
  catch (e) {
1027
1036
  if (/\b(?:unknown|missing|unsupported) tool\b|\btool\b.*\bnot found\b/i.test(String(e?.message || e))) {
1028
1037
  try { await discoverMcpTools(true) } catch { /* the original error is enough */ }
@@ -1155,7 +1164,15 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1155
1164
  identity: () => ({ id: selfAgentId, identifier, slug, name: slug }),
1156
1165
  load: async (cid, root) => toolData(await callMcpTool('list_message_thread', { channel_id: Number(cid), message_id: Number(root) })),
1157
1166
  })
1158
- const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false, isCancelled = () => false }) => {
1167
+ const peerTurnKey = (surface, ...parts) => `peer-turn:${surface}:${parts.map((value) => String(value ?? '?')).join(':')}`
1168
+ const ownTaskCommentKey = (projectId, ticketId, content) => {
1169
+ const normalized = normalizeRenderedMessageText(content)
1170
+ return `own-task-comment:${projectId}:${ticketId}:${normalized.length}:${normalized.slice(0, 120)}:${normalized.slice(-120)}`
1171
+ }
1172
+ const rememberPeerReply = (key, refs = {}) => {
1173
+ if (key) memory.remember({ key, kind: 'peer-turn', state: 'replied', summary: 'Replied once to a peer agent; waiting for a human turn.', refs })
1174
+ }
1175
+ const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false, peerLoopKey = '', isCancelled = () => false }) => {
1159
1176
  if (stopping) throw new Error('Watcher is stopping')
1160
1177
  const deliveryKey = String(key || '')
1161
1178
  if (parentId == null && !validBackendId(channelId)) {
@@ -1163,7 +1180,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1163
1180
  if (channelId == null) throw new Error('No dedicated agent channel available for a new thread')
1164
1181
  projectId = undefined // dedicated channels may be organization-scoped
1165
1182
  }
1166
- const message = String(content || '').trim()
1183
+ const message = compactAgentMessage(content, { maxChars: 1200 })
1167
1184
  if (!deliveryKey || !validBackendId(channelId) || (parentId != null && !validBackendId(parentId)) || !message) return { posted: false, reason: 'invalid-delivery' }
1168
1185
  if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) {
1169
1186
  if (pendingReplyDeliveries.delete(deliveryKey)) persistReplay()
@@ -1186,7 +1203,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1186
1203
  // Store generated threaded replies before delivery. A transport outage must
1187
1204
  // retry the authoritative delivery check, not pay for another model turn.
1188
1205
  if (parentId != null) {
1189
- pendingReplyDeliveries.set(deliveryKey, { key, channelId, parentId, projectId, content: message, skipIfAnyAgentReply, sourceKey, sourceMessageId, allowCancelled })
1206
+ pendingReplyDeliveries.set(deliveryKey, { key, channelId, parentId, projectId, content: message, skipIfAnyAgentReply, sourceKey, sourceMessageId, allowCancelled, peerLoopKey })
1190
1207
  persistReplay()
1191
1208
  }
1192
1209
  const run = (async () => {
@@ -1199,6 +1216,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1199
1216
  deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
1200
1217
  memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: duplicate ? message : rendered.at(-1)?.content, refs: { channelId: Number(channelId), threadId: Number(parentId) }, meta: { discoveredFromBackend: true } })
1201
1218
  if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
1219
+ rememberPeerReply(peerLoopKey, { channelId: Number(channelId), threadId: Number(parentId) })
1202
1220
  log('message delivery ' + deliveryKey + ' already rendered — skipped')
1203
1221
  return { posted: false, reason: duplicate ? 'same-content-rendered' : 'agent-reply-rendered' }
1204
1222
  }
@@ -1223,6 +1241,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1223
1241
  deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
1224
1242
  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 } })
1225
1243
  if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
1244
+ rememberPeerReply(peerLoopKey, { channelId: Number(channelId), ...(parentId != null ? { threadId: Number(parentId) } : {}) })
1226
1245
  return { posted: true, result }
1227
1246
  })().then((result) => {
1228
1247
  if (pendingReplyDeliveries.delete(deliveryKey)) persistReplay()
@@ -1235,10 +1254,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1235
1254
  // Task comments have no channel thread to inspect before delivery. Record the
1236
1255
  // delivery attempt before the mutation and use the comment id as the one-level
1237
1256
  // thread root. An ambiguous mutation error is never replayed automatically.
1238
- const postTaskCommentOnce = async ({ key, projectId, ticketId, parentId, content, sourceKey = '' }) => {
1257
+ const postTaskCommentOnce = async ({ key, projectId, ticketId, parentId, content, sourceKey = '', peerLoopKey = '' }) => {
1239
1258
  if (stopping) throw new Error('Watcher is stopping')
1240
1259
  const deliveryKey = String(key || '')
1241
- const message = String(content || '').trim()
1260
+ const message = compactAgentMessage(content, { maxChars: 1200 })
1242
1261
  if (!deliveryKey || !validBackendId(projectId) || !validBackendId(ticketId) || !validBackendId(parentId) || !message) return { posted: false, reason: 'invalid-delivery' }
1243
1262
  if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) {
1244
1263
  return { posted: false, reason: 'remembered' }
@@ -1253,7 +1272,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1253
1272
  throw new Error('No task-comment tool is advertised by the backend')
1254
1273
  }
1255
1274
  memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: message, refs: { projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(parentId) }, meta: { surface: 'task-comment' } })
1275
+ memory.remember({ key: ownTaskCommentKey(projectId, ticketId, message), kind: 'task-comment', state: 'rendered', summary: message, refs: { projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(parentId) } })
1256
1276
  if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
1277
+ rememberPeerReply(peerLoopKey, { projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(parentId) })
1257
1278
  return { posted: true, result: result.result }
1258
1279
  })().finally(() => messageDeliveries.delete(deliveryKey))
1259
1280
  messageDeliveries.set(deliveryKey, run)
@@ -1262,9 +1283,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1262
1283
 
1263
1284
  async function publishWorkPlan(live, entries, acknowledgement = '') {
1264
1285
  const { control, delivery, taskRef } = live
1265
- 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))
1286
+ 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, 160))
1266
1287
  if (!steps.length || control.cancelled || !control.planKey || memory.has(control.planKey)) return
1267
- const introduction = String(acknowledgement || '').trim().slice(0, 1200) || (control.taskLabel ? `I'm on ${control.taskLabel}.` : "I'll take this on.")
1288
+ const introduction = String(acknowledgement || '').trim().slice(0, 320) || (control.taskLabel ? `I'm on ${control.taskLabel}.` : "I'll take this on.")
1268
1289
  const content = `${introduction}\n\n${steps.map((step, index) => `${index + 1}. ${step}`).join('\n')}`
1269
1290
  const channelId = delivery?.channelId ?? taskRef?.channelId ?? await projectStatusChannel(taskRef?.projectId)
1270
1291
  if (channelId == null || control.cancelled) return
@@ -1834,6 +1855,13 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1834
1855
  const ticketId = hinted && (hinted.id ?? hinted.task_id ?? hinted.taskId)
1835
1856
  const projectId = hinted && (hinted.project_id ?? hinted.projectId ?? raw.project_id ?? raw.projectId)
1836
1857
  if (!validBackendId(ticketId) || !validBackendId(projectId)) { log(kind + ' missing ticket/project identity — ignored'); return }
1858
+ if (kind === 'task:updated') {
1859
+ try { await ensureSelfAgent() } catch { /* stable identifier may still prove authorship */ }
1860
+ if (taskEventSenderIsSelf(raw, { id: selfAgentId, identifier, slug, name })) {
1861
+ log(kind + ' ticket #' + ticketId + ' was updated by this agent — ignored')
1862
+ return { status: 'ignored-self' }
1863
+ }
1864
+ }
1837
1865
  try {
1838
1866
  const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
1839
1867
  const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
@@ -1917,7 +1945,19 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1917
1945
  }
1918
1946
  try { await ensureSelfAgent() }
1919
1947
  catch (error) { log('agent identity lookup unavailable; using configured aliases: ' + (error?.message || error)) }
1948
+ const rootCommentId = messageParentId(comment) ?? Number(commentId)
1949
+ if (memory.has(ownTaskCommentKey(projectId, ticketId, text), 'rendered')) {
1950
+ log(`task:comment project=${projectId} ticket=${ticketId} matches this agent's delivered comment — ignored`)
1951
+ return { status: 'ignored-self' }
1952
+ }
1920
1953
  if (messageSenderIsSelf(comment, { id: selfAgentId, identifier, slug, name })) return { status: 'ignored-self' }
1954
+ const peer = messageSenderIsAgent(comment)
1955
+ 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) } })
1921
1961
  const target = classifyConversationTarget(comment, [...selfAliases], { threadOwned: true })
1922
1962
  if (target.action !== 'handle') {
1923
1963
  log('task:comment ignored before model start (' + target.reason + ')')
@@ -1939,9 +1979,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1939
1979
  log(`task:comment ticket #${ticketId} is not assigned to this agent — ignored`)
1940
1980
  return { status: 'ignored-unassigned' }
1941
1981
  }
1942
- const rootCommentId = messageParentId(comment) ?? Number(commentId)
1943
1982
  const who = senderName(comment)
1944
- const peer = messageSenderIsAgent(comment)
1945
1983
  const ticketSlug = ticketDisplaySlug(ticket)
1946
1984
  memory.remember({ key: sourceKey, kind: 'task-comment', state: 'received', summary: text, refs: { projectId: Number(projectId), ticketId: Number(ticketId), messageId: Number(commentId), threadId: Number(rootCommentId) } })
1947
1985
  const delivery = {
@@ -1952,6 +1990,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1952
1990
  parentId: Number(rootCommentId),
1953
1991
  sourceKey,
1954
1992
  watcherOwned: true,
1993
+ peerLoopKey: peer ? loopKey : '',
1955
1994
  }
1956
1995
  const label = ticketSlug || String(ticket.title || 'the assigned ticket')
1957
1996
  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.' : ''}`
@@ -2074,6 +2113,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2074
2113
  if (!threadOwned || memory.has(sourceKey, 'received')) { log('agent:mention (dup) — skipped'); return }
2075
2114
  log('agent:mention replay recovered for active owned thread')
2076
2115
  }
2116
+ 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) } })
2077
2122
  const target = classifyConversationTarget(msg, [...selfAliases], { threadOwned })
2078
2123
  if (target.action === 'ignore') {
2079
2124
  log('agent:mention ignored before model start (' + target.reason + ')')
@@ -2102,7 +2147,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2102
2147
  // redirect can cancel its process. The watcher delivers the final answer
2103
2148
  // for every runtime after an authoritative last-moment thread check.
2104
2149
  const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result', allowChannelMessaging = false) => cid != null
2105
- ? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: true, allowChannelMessaging }
2150
+ ? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: true, allowChannelMessaging, peerLoopKey: peerMessage ? loopKey : '' }
2106
2151
  : null
2107
2152
  // Under-the-hood model control from chat (view / switch the model the agent runs).
2108
2153
  const mcmd = cid != null ? parseModelCmd(text) : null