Last updated: 2026-07
Markus agents are modelled as single-threaded cognitive entities. Like a human employee, an agent can only focus on one thing at a time. When multiple stimuli arrive concurrently — chat messages, task assignments, status updates, review requests — the agent must make deliberate decisions about what to attend to and in what order.
This is implemented through two core abstractions:
- Agent Mailbox — A priority queue that serialises all incoming stimuli.
- Attention Controller — An event-driven focus manager that processes mailbox items one at a time, with interrupt handling at safe yield points.
Previous designs allowed agents to handle multiple messages simultaneously. This caused:
- Memory contamination — Concurrent conversations polluted each other's session context.
- Cognitive interference — An agent composing a code review could be mid-thought when a chat message hijacked its attention.
- Non-deterministic behaviour — Race conditions in state mutations made debugging nearly impossible.
The mailbox model eliminates these issues by treating the agent's attention as a scarce, serial resource.
External Events Agent Internals
───────────── ───────────────
human_chat ──┐
a2a_message ──┤ ┌──────────────┐
task_status ──┤ enqueue() │ AgentMailbox │ priority queue + dedup
task_comment ──┼────────────────►│ (per agent) │ sorted by priority + FIFO
req_comment ──┤ └──────┬───────┘
review_req ──┤ │ dequeueAsync()
system_event──┤ ▼
heartbeat ────┤ ┌──────────────────┐
session_reply──┤ │ AttentionController│ event-driven loop
daily_report ──┤ │ state: idle → │
callback_result┤ │ focused → idle │
memory_consol──┘ └──────┬───────────┘
│ delegate.processMailboxItem()
▼
┌──────────────┐
│ Agent Core │ handleMessage / executeTask
│ (internal) │ handleHeartbeat / respondInSession
└──────────────┘
PendingCallbackRegistry ──enqueue('callback_result')──► AgentMailbox (async op completions)
All mailbox item types, their metadata, and their processing behaviour are defined in one place: MAILBOX_TYPE_REGISTRY in @markus/shared. Every other module (core routing, attention heuristics, default priorities, frontend filters/labels/icons) reads from this registry. No string literals for type values should appear outside the registry and the shared type union.
// @markus/shared — packages/shared/src/types/mailbox.ts
export const MAILBOX_TYPE_REGISTRY: Record<MailboxItemType, MailboxTypeDescriptor> = {
system_event: { label: 'System Event', defaultPriority: 1, category: 'system', icon: '⚙', activityType: 'internal', createsActivity: true, invokesLLM: true },
human_chat: { label: 'Chat', defaultPriority: 0, category: 'interaction', icon: '💬', activityType: 'chat', createsActivity: true, invokesLLM: true },
task_comment: { label: 'Task Comment', defaultPriority: 2, category: 'task', icon: '💬', activityType: null, createsActivity: false, invokesLLM: false },
mention: { label: 'Mention', defaultPriority: 1, category: 'interaction', icon: '@', activityType: 'chat', createsActivity: true, invokesLLM: true },
session_reply: { label: 'Session Reply', defaultPriority: 1, category: 'task', icon: '↩', activityType: 'respond_in_session', createsActivity: true, invokesLLM: true },
task_status_update: { label: 'Task Status', defaultPriority: 1, category: 'task', icon: '📋', activityType: null, createsActivity: true, invokesLLM: false },
a2a_message: { label: 'Agent Message', defaultPriority: 2, category: 'interaction', icon: '🔗', activityType: 'a2a', createsActivity: true, invokesLLM: true },
review_request: { label: 'Review Request', defaultPriority: 1, category: 'task', icon: '👀', activityType: 'chat', createsActivity: true, invokesLLM: true },
requirement_comment: { label: 'Requirement Comment', defaultPriority: 2, category: 'task', icon: '💬', activityType: null, createsActivity: false, invokesLLM: false },
requirement_update: { label: 'Requirement Update', defaultPriority: 1, category: 'notification', icon: '📝', activityType: 'internal', createsActivity: true, invokesLLM: false },
daily_report: { label: 'Daily Report', defaultPriority: 2, category: 'system', icon: '📊', activityType: 'internal', createsActivity: true, invokesLLM: true },
heartbeat: { label: 'Heartbeat', defaultPriority: 3, category: 'system', icon: '♡', activityType: 'heartbeat', createsActivity: true, invokesLLM: true },
memory_consolidation: { label: 'Memory Consolidation', defaultPriority: 4, category: 'system', icon: '🧠', activityType: 'internal', createsActivity: true, invokesLLM: true },
callback_result: { label: 'Callback Result', defaultPriority: 1, category: 'system', icon: '↩', activityType: 'internal', createsActivity: true, invokesLLM: true },
};export interface MailboxTypeDescriptor {
label: string; // Human-readable display name
defaultPriority: MailboxPriority; // 0=critical, 1=high, 2=normal, 3=low, 4=background
category: MailboxCategory; // Filter group for UI
icon: string; // Emoji/icon for UI display
activityType: string | null; // Derived agent_activities.type (null = set at runtime)
createsActivity: boolean; // Whether processing normally creates an activity record
invokesLLM: boolean; // Whether processing invokes an LLM call
}
export type MailboxCategory = 'interaction' | 'task' | 'notification' | 'system';The frontend uses category for filtering. Users can also filter by individual sourceType.
| Category | Types | Description |
|---|---|---|
interaction |
human_chat, a2a_message, mention |
Direct conversations with humans or agents |
task |
task_status_update, task_comment, requirement_comment, review_request, session_reply |
Task & requirement lifecycle events (including execution triggers) |
notification |
requirement_update |
Status change notifications |
system |
system_event, heartbeat, daily_report, memory_consolidation, callback_result |
Internal agent processes |
task_status_update — Execution vs. Informational
task_status_update serves as the unified trigger for all task lifecycle events. It operates in two modes:
-
Execution mode (
extra.triggerExecution = true): When a task transitions toin_progressand needs execution,TaskService.runTask()sends atask_status_updatewith execution context (onLog, cancelToken, workspace, executionRound) viaagent.sendTaskExecution(). The agent processes this by callingexecuteTask()— the full task execution loop. Priority is set to 1 (high). -
Informational mode (default,
invokesLLM: false): For non-execution status changes (e.g., cancelled, blocked, completed), the item is auto-completed without LLM invocation. The agent logs the status change for awareness but does not spend tokens processing it. These transitions are handled by the system (FSM side-effects) and need no agent action.
When updateTaskStatus() triggers auto-start execution, the separate notification is skipped to avoid redundant processing. The execution-mode task_status_update serves as both trigger and notification.
Similarly, when a task transitions from in_progress to review (via task_submit_review), the assignee notification is skipped — the assignee itself initiated the submission and already knows the state change. Only the reviewer receives a review_request notification.
Silent Transitions: Certain system-managed transitions never produce a task_status_update notification because the real mechanism (cancel token or auto-start) already handles the work:
| Transition | Reason suppressed |
|---|---|
blocked -> in_progress |
Auto-start fires a separate execution-mode item |
in_progress -> blocked |
Cancel token stops execution; notification is redundant |
These are defined in TaskService.SILENT_TRANSITIONS and checked early in maybeNotifyAssignee().
requirement_update — Conditional LLM Processing
requirement_update is invokesLLM: false by default. Most requirement status transitions are informational — the agent is auto-notified without an LLM call.
There are three exceptions where extra.actionRequired = true triggers an LLM call with scenario: 'requirement_action':
- Approval (
priority: 1): When a requirement is approved, the creator agent is prompted to create tasks to fulfill it. This is high priority because the user expects immediate follow-up. - Rejection (
priority: 1): When a requirement is rejected, the agent decides whether to resubmit with updates viarequirement_resubmitor abandon the requirement. - All tasks done — review needed (
priority: 2): When all linked tasks reach terminal state, instead of auto-completing the requirement, the system notifies the creator agent. The agent reviews results and decides whether to mark the requirement ascompletedor create additional tasks.
requirement_comment — Direct Discussion on Requirements
requirement_comment is dedicated to threaded comments on requirements (analogous to task_comment for tasks). Unlike requirement_update (which covers status/decision notifications), requirement_comment represents interactive dialogue — questions, feedback, coordination.
Processing follows the same pattern as task_comment: createsActivity: false, invokesLLM: false. If the requirement has an active agent context, the comment is injected; otherwise it falls back to handleMessage with scenario: 'comment_response', following the context-first protocol (§3.5).
task_comment — Live Session Injection
task_comment has a unique behaviour: when the referenced task is actively being executed, the comment is injected into the running LLM session (injectUserMessage) rather than creating a new activity. This means:
createsActivity: false— it does not always create an activity (but the merge decision IS recorded)invokesLLM: false— it does not invoke a new LLM call; the injected text becomes part of the current task's next LLM turn- If the task is NOT active, it falls back to
handleMessageand DOES create an activity
When processing task_comment (on inactive tasks) or requirement_update (comments), the agent uses scenario: 'comment_response'. This instructs the LLM to follow a mandatory context-gathering protocol before replying:
- Fetch the full item — call
task_getorrequirement_listto get the complete current state - Read ALL previous comments — understand the full conversation thread
- Identify the commenter's intent — question, request, feedback, objection, etc.
- Check related context — look up referenced tasks, requirements, or files
Only after completing these steps does the agent formulate its reply. This prevents superficial responses that ignore important context already discussed in the comment thread.
The notification text sent to agents also includes explicit MANDATORY instructions reinforcing this protocol.
Structural reply-to: Both task_comment and requirement_comment tools support a reply_to_comment_id parameter that creates a structural link to the parent comment. The task_comments and requirement_comments tables have a reply_to_id column; when queried, a LEFT JOIN returns the parent comment's author and content snippet (replyToAuthor, replyToContent). The notification payload also injects an agent streak count (consecutive agent-only comments) to help agents decide whether further replies are warranted.
Batch awareness: The comment_response scenario prompt instructs agents to handle multiple bundled comments (separated by ---) as a single consolidated reply, using reply_to_comment_id for the most important one and quoting others inline.
Priorities can be overridden per-item when enqueuing.
When a reviewer (agent or human) posts task_comment during an active review (task.status === 'review' and authorId === task.reviewerId), the automatic task_comment notifications to the worker and creator agents are suppressed. Only explicit @mention notifications are delivered.
Rationale: Without this guard, the reviewer's intermediate comments trigger a notification cascade:
- Reviewer posts comment → worker/creator receive
task_comment - Worker processes notification with full LLM → may send A2A message back to reviewer
- Reviewer processes A2A message → posts redundant second review
The review outcome is properly communicated through the status transition (completed or in_progress revision), not intermediate comments.
callback_result — Async Operation Completions
callback_result delivers the outcome of asynchronous operations back to the originating agent through the mailbox (priority 1 — high). It replaces the legacy pattern of injecting results directly into an active LLM session via injectUserMessage, ensuring completions enter the attention loop like any other stimulus.
Typical sources:
background_execcompletion — When a background shell process finishes, the agent receives acallback_resultwith exit code, duration, and stdout/stderr tail.- In-session A2A await — When an agent sends
agent_send_messagewithawait_in_session: true, ana2a_replycallback is registered (correlated byconversation_id). The peer's reply is routed back into the origin session instead of a separate a2a session.
Two delivery forms. A resolved callback is delivered one of two ways, selected by deliveryMode:
in_session→ enqueues acallback_resultbound tooriginSessionId, resuming the current conversation (used forbackground_execcompletions andawait_in_sessionA2A replies).mailbox→ enqueues asystem_event, a fresh attention cycle (used forschedule_wakeupfirings and autonomous follow-ups).
Payload shape (payload.extra):
{
callbackId: string; // Registry ID (matches pending callback)
originSessionId: string; // Session to resume context in (in_session mode)
callbackType: 'background_exec' | 'wakeup' | 'a2a_reply';
correlationId?: string; // conversation_id for a2a_reply
exitCode?: number; // For background_exec
}Processing: in_session callbacks route to handleMessage() with the originating session (originSessionId) so the agent continues where it left off. Falls back to a fresh system session if origin is unknown. invokesLLM: true, createsActivity: true.
Registration flow: when an agent starts an async operation the completion is registered as a PendingCallback in PendingCallbackRegistry and persisted to SQLite:
background_exec— thebackground_exectool path callsregisterBackgroundSession()(fromAgent.executeTool, keyed by the returned bg session id,originSessionId = active session).agent_send_messagewithawait_in_session— registers ana2a_replycallback keyed byconversation_id.schedule_wakeup— registers awakeupcallback with awakeAttimestamp (and optionalrecurringMs).
On completion/firing the registry entry is resolved and delivered via the shared Agent.deliverCallback() helper according to its deliveryMode.
See §11.3 for the full PendingCallbackRegistry specification.
idle ──► focused ──► idle
│ ▲
▼ │
deciding
- idle — Waiting for mail. Blocks on
mailbox.dequeueAsync(). - focused — Processing a single mailbox item. All new mail triggers an interrupt signal.
- deciding — Evaluating whether to continue, preempt, cancel, merge, or defer (at a yield point).
There is no polling. When a new item arrives while the agent is focused:
- The mailbox emits
mailbox:new-itemvia the EventBus. - The
AttentionControllersets an interrupt signal. - At the next safe yield point (between LLM turns in the tool loop), the agent calls
checkYieldPoint(). - The controller evaluates the pending item using heuristics first (fast, no LLM call), then optionally falls back to an LLM interrupt judge for ambiguous cases where heuristics return
continue. The LLM judge sees both the current work context and the full content of the new message, enabling it to understand semantic intent (e.g., "stop publishing" → cancel, "hold off for now" → preempt).
| Decision | Effect |
|---|---|
pick |
Item dequeued from idle state (initial selection) |
continue |
New item not urgent enough — keep working on current focus |
preempt |
Pause current work — session is deferred for later resumption. The agent switches to the new item. When the agent becomes idle, resurfaceDue() automatically re-queues the deferred item for continued processing. |
cancel |
Permanently stop current work — the item is dropped and will NOT be resumed. Used when the new message explicitly revokes or contradicts the current work (e.g., "cancel that task", "don't publish this"). |
merge |
Absorb new item into current work (e.g., comment on current task) |
defer |
Explicitly postpone the new item |
delegate |
Hand off to another agent (future) |
drop |
Discard the item |
These two decisions handle fundamentally different situations:
- Preempt (pause): "暂停" — The current work is saved. The mailbox item is moved to
deferredstatus and will automatically resurface when the agent is idle. Session context (conversation history) is preserved bysessionId, so processing continues from where it left off. - Cancel (stop): "取消" — The current work is permanently abandoned. The mailbox item is marked as
completedand will NOT be resumed. Used when the incoming message explicitly revokes or invalidates the current work.
The heuristic interrupt rules (heuristicDecision) never produce cancel — only the LLM interrupt judge can make this semantic distinction, since it requires understanding message content (e.g., distinguishing "wait before deploying" from "cancel the deployment entirely").
User chat (human_chat), task comments (task_comment), and requirement comments (requirement_comment) are assigned priority 0 (critical) — the highest possible level. The heuristic rules enforce:
- R0: If a new
human_chatarrives from the same user while the agent is already processing that user's previous message, the new message is merged (injected into the active session as a follow-up). This avoids unnecessary preemption and allows natural multi-message conversations. - R1: If a new
human_chat,task_comment, orrequirement_commentarrives while the agent is focused on any non-user work, the agent always preempts to handle the user interaction immediately. - When idle with multiple items queued, the priority queue ensures user interactions are dequeued first.
- The agent's system prompt includes the full mailbox queue (not a truncated view), so the agent is always aware of everything waiting for its attention.
- Deliberation abort: If a
human_chatarrives during active deliberation, the deliberation is aborted immediately and the user message is processed first. Deliberation results are discarded; items remain in queue for next cycle.
Yield points are inserted at natural pauses in the agent's processing pipeline:
- Task execution (
_executeTaskInternal): After all tool calls complete and before the next LLM turn. Bothpreemptandcanceldecisions are honoured:- Preempt → task session state is saved, mailbox item deferred. When the agent is idle, the deferred item resurfaces and execution resumes with full session context.
- Cancel → mailbox item is dropped permanently. The task may need separate status updates (e.g.,
cancelTask) depending on the cancelling message's intent.
- Chat/message handling (
handleMessage): After tool results are recorded. Interrupt behaviour depends on the scenario:- Non-preemptable (
chatonly): Only merge decisions are honoured (no preemption/cancellation), since a human user is awaiting a direct response. If a preempt/cancel decision is returned, the interrupt signal is restored (not consumed) so the high-priority item is processed immediately after the chat completes. - Preemptable (all other scenarios:
a2a,comment_response,requirement_action,review,heartbeat,memory_consolidation): Full preemption/cancellation is allowed. These either have no real-time human caller waiting, or the caller (another agent, a system process) can tolerate the interruption.
- Non-preemptable (
- Streaming chat (
handleMessageStream): After tool results are recorded. Only merge is honoured (same as non-preemptablehandleMessage), since a human is receiving a streamed response. Preempt/cancel decisions restore the interrupt signal for immediate processing after the stream completes.
When an agent is stopped (Agent.stop()) or paused (Agent.pause()):
- Active LLM stream is cancelled via
cancelActiveStream()— any in-flight LLM response is aborted immediately. - Attention loop stops —
AttentionController.stop()setsrunning = falseand wakes any blockeddequeueAsync(). - In-flight item is preserved — if processing was in progress, the item is requeued (not lost) so it can be picked up when the agent resumes.
- Deferred items survive — items deferred by preemption remain in the database and will be resurfaced when the agent restarts.
The attention loop wraps processMailboxItem in a generous backstop
(MAILBOX_PROCESSING_TIMEOUT_MS, or APPROVAL_WAIT_TIMEOUT_MS while awaiting approval)
via Promise.race in processFocusedItem. On timeout the item is requeued. Today the
original processing promise is not cancelled — it may still be running, so a requeue can
re-run tools and double side effects.
- Behavior: when the backstop fires, the in-flight processing is cancelled
(
AbortController/cancelToken shared with the LLM/tool path), and a single-flight generation guard ensures that if the original processing resolves after the timeout, its result is discarded (not persisted, not streamed, not re-completing the item). - Invariants:
- After a timeout-requeue, the tool side effects and persistence of the timed-out attempt happen at most once (the orphaned late result is dropped).
- Requeue still occurs so the item is not lost.
- Normal (non-timeout) completion is unaffected.
- Design rationale (Hermes): an interrupted/aborted API call must not inject a half-finished result; discard in-flight work rather than racing two writers.
- Testing (
packages/core/test/attention.test.ts— "A1: cancels in-flight processing on backstop timeout…"): a slow turn hits the backstop →cancelProcessingis invoked and the item is requeued; the original turn resolving late is dropped (no extra requeue/complete). The backstop is injectable viasetProcessingTimeoutMsfor deterministic testing. - Status: implemented (
AttentionController.processFocusedItemcancel-on-timeout +AttentionDelegate.cancelProcessing, wired inAgent.createAttentionDelegate).
The Streaming chat yield point (above) is non-preemptable by design (a human awaits the
reply). B3 keeps that principle but distinguishes revocation from preemption: an explicit
cancel decision aborts the in-flight stream and drops the item ([cancelled]), while a
preempt restores the signal so the higher-priority item runs right after the turn (no
mid-answer truncation). Backstop-timeout abort is shared with A1's cancelProcessing.
- Status: implemented. Full behavior/invariants/tests live in STREAMING-AND-REATTACH.md §4.2.
All external code MUST interact with agents through the mailbox API. Direct calls to internal methods like handleMessage(), executeTask(), or respondInSession() are forbidden — those are private implementation details of the attention loop.
Used for notifications that don't need a response:
agent.enqueueToMailbox('task_comment', {
summary: 'Comment on task X',
content: notificationText,
taskId: 'task_123',
}, {
metadata: { senderName: 'Alice', senderRole: 'user' },
});Used when the caller needs a text response:
const reply = await agent.sendMessage(
userText, senderId, senderInfo,
{ sourceType: 'human_chat', images, toolEventCollector }
);Used for SSE/streaming chat where events are streamed back via callback:
const reply = await agent.sendMessageStream(
userText, onEvent, senderId, senderInfo, cancelToken, images
);Used to run a task through the mailbox. Internally enqueues a task_status_update with extra.triggerExecution = true. Fire-and-forget with streaming log callback:
void agent.sendTaskExecution(
taskId, taskDescription, onLog, cancelToken, taskProjectContext, executionRound
);Used for post-task comment replies within an existing session:
const reply = await agent.sendSessionReply(
taskSessionId, prompt, onLog, senderId, senderInfo
);Internally, all awaitable methods create a promise, store its resolve/reject in the mailbox item's metadata, enqueue, and return the promise. The attention controller processes the item, and processMailboxItemInternal resolves the promise with the reply.
This is a critical design invariant. The following internal methods invoke the LLM but must NEVER be called from outside agent.ts:
| Internal method | Routed through | Mailbox type |
|---|---|---|
handleMessage() |
sendMessage() |
human_chat, a2a_message, system_event, etc. |
handleMessageStream() |
sendMessageStream() |
human_chat (with extra.stream) |
executeTask() |
sendTaskExecution() |
task_status_update (with extra.triggerExecution) |
respondInSession() |
sendSessionReply() |
session_reply |
handleHeartbeat() |
heartbeat:trigger → mailbox.enqueue('heartbeat') |
heartbeat |
generateDailyReport() |
internally calls sendMessage() |
daily_report |
dreamConsolidateMemory() |
internally calls sendMessage() |
memory_consolidation |
| async op completion handler | PendingCallbackRegistry.resolve() → enqueueToMailbox('callback_result') |
callback_result |
| Method | Path | Description |
|---|---|---|
| GET | /api/agents/:id/mind |
Current mind state (attention, focus, queue snapshot) |
| GET | /api/agents/:id/mailbox |
Mailbox timeline — enriched history (see below) |
| GET | /api/agents/:id/decisions |
Decision timeline (recent + persisted) |
Query params: limit, offset, type (filter by source_type, comma-separated), category (filter by category from registry).
Response shape:
{
"queued": [ /* items currently in queue */ ],
"queueDepth": 3,
"history": [
{
"id": "mbx_...",
"sourceType": "human_chat",
"priority": 1,
"status": "completed",
"summary": "What's the status of...",
"queuedAt": "2026-04-10T...",
"startedAt": "2026-04-10T...",
"completedAt": "2026-04-10T...",
"decisions": [
{ "id": "dec_...", "decisionType": "pick", "reasoning": "Idle — picked from queue" }
],
"activity": {
"id": "act-...",
"type": "chat",
"label": "Chat with Owner",
"totalTokens": 1234,
"totalTools": 3,
"success": true,
"startedAt": "...",
"endedAt": "..."
}
}
]
}Each history item is self-contained: the mailbox stimulus, the decision(s) made, and the resulting activity summary. Activity logs are fetched separately on demand via GET /api/agents/:id/activity-logs?activityId=....
| Event Type | Payload | When |
|---|---|---|
agent:mailbox |
{ agentId, item } |
New item enqueued |
agent:decision |
{ agentId, decision } |
Attention decision made |
agent:attention |
{ agentId, state, currentFocus } |
Attention state changed |
agent:focus |
{ agentId, focus, mailboxDepth } |
Focus target changed |
mailbox_items (1) ──────┬──── (0..N) agent_decisions (what the agent decided)
│
└──── (0..1) agent_activities (what the agent did)
│
└── (0..N) agent_activity_logs (LLM turns, tool calls)
mailbox_items is the primary timeline. Everything else hangs off it. The frontend queries mailbox history, and for each item can look up its decision(s) and execution log.
CREATE TABLE mailbox_items (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
source_type TEXT NOT NULL, -- MailboxItemType enum value
priority INTEGER NOT NULL DEFAULT 2,
status TEXT NOT NULL DEFAULT 'queued',
payload TEXT NOT NULL DEFAULT '{}', -- JSON (summary, content, taskId, etc.)
metadata TEXT DEFAULT '{}', -- JSON (senderId, senderName, etc.)
queued_at TEXT NOT NULL,
started_at TEXT,
completed_at TEXT,
deferred_until TEXT,
merged_into TEXT
);
CREATE INDEX idx_mailbox_agent_status ON mailbox_items(agent_id, status);
CREATE INDEX idx_mailbox_agent_queued ON mailbox_items(agent_id, queued_at DESC);
CREATE INDEX idx_mailbox_agent_type ON mailbox_items(agent_id, source_type, queued_at DESC);
CREATE TABLE agent_decisions (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
decision_type TEXT NOT NULL, -- DecisionType enum value
mailbox_item_id TEXT NOT NULL, -- FK → mailbox_items.id
context TEXT NOT NULL DEFAULT '{}',
reasoning TEXT NOT NULL DEFAULT '',
outcome TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX idx_decisions_agent ON agent_decisions(agent_id, created_at DESC);
CREATE INDEX idx_decisions_mailbox_item ON agent_decisions(mailbox_item_id);
CREATE TABLE agent_activities (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
mailbox_item_id TEXT, -- FK → mailbox_items.id (the causal link)
type TEXT NOT NULL, -- derived from mailbox source_type (see §6.3)
label TEXT NOT NULL,
task_id TEXT,
started_at TEXT NOT NULL,
ended_at TEXT,
total_tokens INTEGER DEFAULT 0,
total_tools INTEGER DEFAULT 0,
success INTEGER DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_activities_agent ON agent_activities(agent_id, started_at DESC);
CREATE INDEX idx_activities_mailbox_item ON agent_activities(mailbox_item_id);
CREATE TABLE agent_activity_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activity_id TEXT NOT NULL REFERENCES agent_activities(id) ON DELETE CASCADE,
seq INTEGER NOT NULL,
type TEXT NOT NULL, -- 'status' | 'text' | 'tool_start' | 'tool_end' | 'error' | 'llm_request'
content TEXT NOT NULL DEFAULT '',
metadata TEXT DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_activity_logs_activity ON agent_activity_logs(activity_id, seq);The agent_activities.type field is not an independent enum. It is deterministically derived from mailbox_items.source_type using the mapping in @markus/shared:
mailbox_items.source_type |
agent_activities.type |
Notes |
|---|---|---|
human_chat |
chat |
|
a2a_message |
a2a |
|
task_comment |
(none or chat) |
Active task → inject only (no activity); inactive → chat |
requirement_comment |
(none or chat) |
Same pattern as task_comment: inject or fallback to chat |
task_status_update |
task or (none) |
Execution mode → task; informational → auto-completed (no activity) |
requirement_update |
internal or (none) |
actionRequired → LLM call; otherwise auto-completed (no activity) |
mention |
chat |
|
review_request |
chat |
|
session_reply |
respond_in_session |
Has task_id |
heartbeat |
heartbeat |
|
callback_result |
internal |
Resumes originating session |
system_event |
internal |
|
daily_report |
internal |
|
memory_consolidation |
internal |
The activityType is set to null in the registry for task_comment, requirement_comment, and task_status_update because it depends on the processing mode. Agent.startActivity() sets the type explicitly: executeTask() creates a task activity, while handleMessage() creates the appropriate type based on scenario.
The key schema change is adding mailbox_item_id to agent_activities. Since mailbox_item_id is nullable (historical activities predate the mailbox system), this is a non-breaking additive migration:
ALTER TABLE agent_activities ADD COLUMN mailbox_item_id TEXT;
CREATE INDEX IF NOT EXISTS idx_activities_mailbox_item ON agent_activities(mailbox_item_id);Historical activities without mailbox_item_id remain valid but lack the causal link. All new activities created after the migration will have the link set.
- Enqueued →
mailbox_itemsrow withstatus = 'queued' - Dequeued by attention controller →
status = 'processing',started_atset - Decision made →
agent_decisionsrow referencingmailbox_item_id - Activity started →
agent_activitiesrow withmailbox_item_idset - LLM turns / tool calls →
agent_activity_logsrows referencingactivity_id - Completed →
mailbox_items.status = 'completed',completed_atset; activityended_atset - If deferred →
status = 'deferred'with optionaldeferred_until - If merged →
status = 'merged'withmerged_intopointing to the absorbing item
Every task and requirement status transition generates a task_status_update or requirement_update mailbox notification to the assigned/creator agent. This ensures:
- The agent is always aware of state changes, even those initiated externally (human approval, timeout, cascade cancellation).
- State transitions are recorded in the mailbox timeline, providing full traceability.
- The agent can react to state changes (e.g., start working when approved, reflect when rejected).
See STATE-MACHINES.md for the full FSM specifications.
The mailbox system feeds into the agent's memory layers:
| Layer | Source | Cadence |
|---|---|---|
| Episodic | Every mailbox item processed becomes an activity in agent_activities — searchable via recall_activity |
Per interaction |
| Semantic | Patterns extracted from decision history via consolidation → MEMORY.md | Periodic (Dream Cycle) |
| Procedural | Recurring patterns may inform ROLE.md evolution | Rare |
The full stimulus/response record (mailbox_items + agent_decisions) and the action/outcome record (agent_activities + agent_activity_logs) together form the agent's episodic memory — retrievable via the recall_activity tool.
See MEMORY-SYSTEM.md for details.
The Agent Mind tab provides a unified view of the agent's cognitive state and history. There are no sub-tabs — it is a single scrollable view.
┌─────────────────────────────────────────────────────┐
│ [IDLE/FOCUSED/DECIDING] Focused on: [type] label │ ← Attention state header
│ Queue: 3 items waiting ↻ │
├─────────────────────────────────────────────────────┤
│ Filter: [All] [Interaction] [Task] [Notification] │ ← Category filters from registry
│ [System] + type dropdown for fine filter │
├─────────────────────────────────────────────────────┤
│ ● human_chat "What's the status of..." completed │ ← Mailbox timeline
│ └─ [pick] Idle — picked from queue │ (each item expandable)
│ └─ Activity: Chat with Owner 1.2k tokens 3 tools│
│ └─ (click to load activity logs) │
│ │
│ ● task_status_update "Implement feature X" completed │
│ ● heartbeat "Heartbeat check-in" completed │
│ ● task_comment "Comment on task..." merged │
│ └─ [merge] Comment on active task — merged │
│ │
│ [Load Earlier...] │
└─────────────────────────────────────────────────────┘
- Mailbox is the primary timeline — not activities, not decisions. Those are detail views within each mailbox item.
- Filters use
categoryfrom the registry — adding a new mailbox type automatically makes it filterable without frontend changes. - Labels, icons, and colors all come from the registry — the frontend reads
MAILBOX_TYPE_REGISTRYat render time. - Expandable detail — clicking a mailbox item shows: (a) decision badge(s), (b) activity summary, (c) lazy-loaded activity logs.
- Status indicators:
completed(green),processing(blue pulse),merged(blue),deferred(purple),dropped(red),queued(amber).
All paths that invoke the LLM are routed through the mailbox:
- Human chat (HTTP / WebUI / gateway) →
sendMessage/sendMessageStream - Task execution (runTask / runTaskFresh) →
sendTaskExecution - Post-task comment reply →
sendSessionReply - Daily report (API trigger) →
generateDailyReport→sendMessage(sourceType: 'daily_report') - Heartbeat (periodic timer) →
heartbeat:triggerevent →mailbox.enqueue('heartbeat')— includes active goals review and timed-out callback checks (§11.4) - Memory consolidation (dream cycle) →
dreamConsolidateMemory→sendMessage(sourceType: 'memory_consolidation') - Cross-agent messages (A2A / delegation) → DM channel →
enqueueToMailbox('a2a_message')(§11) - Async operation completions (
background_exec, etc.) →PendingCallbackRegistry→enqueueToMailbox('callback_result') - Notifications (task status, requirement, comments) →
enqueueToMailbox
No LLM call is made outside this architecture.
Inter-agent messaging is fully consolidated into the Mailbox system. The legacy A2ABus class has been retired — it added a redundant routing layer when the mailbox already provides serialised attention handling.
A2A messages now route through deterministic DM channels that reuse the group-chat infrastructure for persistence, WebSocket delivery, and mailbox enqueue:
Channel key: dm:a2a:{sorted_id_1}:{sorted_id_2}
(agent IDs sorted lexicographically — one channel per pair)
Agent A Agent B
──────── ────────
tool: agent_send_message ──► AgentManager.sendMessage()
│
▼
ensureDmChannel(dm:a2a:...)
│
▼
sendGroupMessage(channelKey, ...)
├─ persist to channel_messages
├─ WebSocket broadcast
└─ enqueueToMailbox('a2a_message', { extra: { channelKey } })
│
▼
AttentionController picks it up
(stable session: channel_{channelKey}_{agentId})
Benefits over ephemeral session IDs:
- Persistent history — both agents recall past exchanges via
recall_contextwithscope: "channel"andchannel_key. Personal user↔agent DM chat sessions (chat_sessionstable) userecall_contextwithscope: "chat_session"andsession_id(pagination viabefore/limit) — see LEARNING-LOOP.md §9.3. - Stable sessions — all messages in a pair share one session ID derived from the channel key
- Enqueue-time dedup — messages from the same
channelKeycoalesce (§14) - Group chat parity — DM channels use the same
groupChatRepo, member resolution, and API paths as custom group chats
When groupChatHandlers are not wired (e.g., unit tests), A2A falls back to direct sendMessage() with sourceType: 'a2a_message' and the same channelKey in extra.
agent_send_message: Always asynchronous — returns immediately withconversation_idandchannel_key. Messages are tagged[conversation:...]for multi-turn correlation.agent_delegate_task: UsesDelegationManagerfor protocol orchestration; transport goes through the DM channel path above.agent_broadcast_status: Lightweight path — writes to target agent's daily log without an LLM call. Does not use DM channels.agent_send_group_message: Team/custom group channels (group:<teamId>,group:custom:<id>). @mentions control which peer agents receivea2a_messagemailbox items.
The @markus/a2a package retains DelegationManager and protocol types. A2ABus is exported with a @deprecated annotation for backward compatibility only.
PendingCallbackRegistry (packages/core/src/pending-callback.ts) tracks async operations that must report results back through the mailbox rather than injecting directly into an active session.
type CallbackType = 'background_exec' | 'wakeup' | 'a2a_reply';
type CallbackDelivery = 'in_session' | 'mailbox';
interface PendingCallback {
id: string; // Unique callback ID (e.g., background session ID, a2a_<conversation_id>)
agentId: string; // Originating agent
originSessionId: string; // Session to resume on completion (in_session mode)
type: CallbackType; // Operation type
deliveryMode?: CallbackDelivery; // in_session (default) → callback_result; mailbox → system_event
command?: string; // Optional context (shell command, etc.)
note?: string; // Free-text label (wakeup reason, delegation goal)
correlationId?: string; // Correlates an external event (conversation_id)
wakeAt?: number; // Scheduled wakeups: epoch ms when due
recurringMs?: number; // Recurring wakeups: re-arm interval
registeredAt: number; // Epoch ms
timeoutMs: number; // 10 min for background_exec; 30 min for a2a_reply; effectively ∞ for wakeup
}Lifecycle:
- Register — An async operation is registered as a
PendingCallbackand persisted viaSqlitePendingCallbackRepo:background_exec→registerBackgroundSession()(called fromAgent.executeToolon the tool result).agent_send_messagewithawait_in_session→ ana2a_replycallback keyed byconversation_id.schedule_wakeup→ awakeupcallback withwakeAt(+ optionalrecurringMs).
- Complete / fire — On completion (
background_exec,a2a_reply) or when a wakeup is due, the entry is resolved and delivered via the sharedAgent.deliverCallback()helper:in_session→callback_result(bound tooriginSessionId);mailbox→system_event. Recurring wakeups re-arm. - Timeout — Heartbeat calls
getTimedOut()to find expired callbacks, thenexpireTimedOut(id)removes each from the registry. Timed-out operations are surfaced in the heartbeat prompt (§11.4) for agent investigation — they do not silently disappear. (Wakeups use an effectively infinite timeout and are never flagged.)
Wakeup scheduler: each agent runs a coarse (~1-minute) sweep (Agent.sweepDueWakeups) that fires any wakeup callbacks whose wakeAt has passed. This lets an agent register precise time-based follow-ups (schedule_wakeup) and stay idle in between, rather than relying on the periodic heartbeat.
Heartbeat as coarse safety-net: the periodic heartbeat is a fallback patrol (DEFAULT_HEARTBEAT_INTERVAL_MS, 6h), not the primary timing mechanism — schedule_wakeup is. The interval is configurable live by the user (Heartbeat tab / PATCH /api/agents/:id/config) and by the agent itself via the set_heartbeat_interval tool (clamped 5min–24h). Both paths go through Agent.setHeartbeatInterval(), which restarts the scheduler immediately; agent-initiated changes persist via the agent:heartbeat-interval-changed event → agentRepo.updateConfig.
The registry is a process singleton (pendingCallbackRegistry), restored from SQLite on startup so callbacks survive server restarts.
Periodic heartbeat items (priority: 3 — low) drive proactive agent patrol. Beyond the standard checklist, heartbeat now integrates:
Active goals review — When goalFetcher is wired (from org-manager requirement service), the heartbeat prompt includes an Active Goals section listing each goal's title, status, iteration count (currentIteration / maxIterations), and completion criteria. The agent is instructed to:
- Check linked tasks for progress
- Create follow-up tasks if needed
- Mark requirements complete when criteria are met
- Escalate or adjust approach if stuck
Callback timeout handling — Before building the heartbeat prompt, handleHeartbeat() calls pendingCallbackRegistry.getTimedOut(). For each expired callback:
expireTimedOut(id)removes it from the registry (and persistence)- Details are injected into the heartbeat prompt under Timed-Out Async Operations (background exec or an awaited A2A delegation whose reply never arrived)
- The agent investigates and takes corrective action during the heartbeat LLM session
This ensures orphaned async operations are surfaced even when the completion handler never fires (process crash, hung command, etc.).
Background process completions that do finish normally are still routed as separate callback_result mailbox items (priority 1) — the heartbeat section only handles the timeout case.
The wait_for_reply=true parameter is deprecated and ignored. All A2A messaging is non-blocking. This eliminates deadlocks that occurred when two agents simultaneously awaited each other's reply.
Correlation pattern: Each message carries a conversation_id (auto-generated UUID). Agents record pending questions in working memory and recognize replies by the [conversation:...] tag prepended to the message content. Multi-turn exchanges use the same conversation_id for context continuity.
Just as agents have a mailbox for incoming stimuli, users have a persistent notification system for events that require their attention. This is the user-facing counterpart to the agent mailbox.
CREATE TABLE user_notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
type TEXT NOT NULL, -- UserNotificationType
title TEXT NOT NULL,
body TEXT DEFAULT '',
priority TEXT DEFAULT 'normal', -- 'low' | 'normal' | 'high' | 'urgent'
read INTEGER DEFAULT 0,
action_type TEXT, -- 'navigate' | 'open_chat' | null
action_target TEXT, -- JSON: { path } or { agentId, sessionId }
metadata TEXT DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);Analogous to MAILBOX_TYPE_REGISTRY, user notification types are defined in USER_NOTIFICATION_TYPE_REGISTRY in @markus/shared:
| Type | Label | Icon | Default Priority | Action | Category |
|---|---|---|---|---|---|
approval_request |
Approval Request | 🔐 | high | navigate | approval |
task_created |
Task Created | 📋 | normal | navigate | task |
task_completed |
Task Completed | ✅ | normal | navigate | task |
task_review |
Task Review | 👁️ | normal | navigate | task |
task_failed |
Task Failed | ❌ | high | navigate | task |
requirement_created |
Requirement Proposed | 📝 | high | navigate | task |
requirement_decision |
Requirement Decision | ⚖️ | normal | navigate | task |
agent_report |
Agent Report | 📊 | normal | none | agent |
system |
System | ⚙️ | normal | none | system |
Notifications can be actionable — clicking them navigates the user to the relevant context:
action_type |
action_target format |
Behaviour |
|---|---|---|
navigate |
{ "path": "/work?task=T123" } |
Navigate to the specified route |
open_chat |
{ "agentId": "...", "sessionId": "..." } |
Open chat with agent, resume session |
none |
— | Notification only, no navigation |
| Method | Path | Description |
|---|---|---|
| GET | /api/notifications?type=...&limit=...&offset=... |
List notifications with filtering, returns totalCount and unreadCount |
| POST | /api/notifications/:id/read |
Mark single notification as read |
| POST | /api/notifications/mark-all-read |
Mark all notifications as read |
When a notification is created (via HITLService.notify()), a WebSocket notification event is broadcast. The frontend App.tsx listens for this and dispatches a markus:notifications-changed custom DOM event, which triggers the NotificationBell component to refresh.
Agents have two distinct modes for communicating with users, reflecting different conversational intents:
Proactive messages that appear in the agent's chat and the user's notification bell. The user can reply in chat, and the agent has full context of what it sent.
// Tool schema
{
name: 'notify_user',
parameters: {
title: string, // Short headline (1 line)
body: string, // Full message content (visible in chat)
priority: 'low' | 'normal' | 'high' | 'urgent', // optional, default 'normal'
related_task_id?: string, // deep-link to task if applicable
target_user_id?: string // target specific user (defaults to currentInteractingUserId)
}
}When to use: Status updates, task completion notices, FYI messages, findings, alerts — any proactive communication where the user may want to reply.
Flow: agent.executeTool('notify_user') → builds formatted message with embedded context → memory.appendMessage() (in-memory session) → eventBus.emit('agent:notify-user') → start.ts handler: chatSessionRepo.appendMessage() (with notifyUser metadata) + ws.broadcastProactiveMessage() (with metadata) + hitlService.notify()
Notification routing: With related_task_id → actionType: 'navigate' to Work page. Without task → actionType: 'open_chat' with sessionId to agent's chat.
Every notify_user message is persisted with two layers of context:
-
DB metadata (
metadatacolumn):{ notifyUser: true, priority, taskId?, requirementId? }— used by the frontend to display context links and badges. -
Embedded context comment (appended to message content):
<!-- notify_context: task_id=xxx, requirement_id=yyy -->— survives throughrestoreSessionFromHistory()(which only readsrole+content), ensuring the agent retains context about what it notified the user about when the user replies.
The agent's chat scenario system prompt instructs it to parse these notify_context references and use tools like recall_activity or search_tasks to retrieve full context before responding to user follow-ups.
Referencing resources in the body:
notify_userbodies (and any chat/comment/report markdown) should reference Markus resources using the conventions in PROMPT-ENGINEERING.md §2.2 "Referencing Markus Resources" — bare IDs (tsk_…,dlv_…, …), titled links[Title](task:tsk_…), or a reference alone on its own line to render a card. This is separate from therelated_task_idmetadata (which drives the notification's deep-link badge).
All notify_user messages are buffered in the frontend for every agent conversation immediately upon WebSocket receipt — regardless of whether the user is currently viewing that agent's chat. This ensures messages are visible as soon as the user navigates to the agent, without requiring a page reload.
Requests a decision or approval from the user. The tool blocks until the user responds — no timeout. Supports default Approve/Reject options, custom options, and optional freeform text input.
// Tool schema
{
name: 'request_user_approval',
parameters: {
title: string, // Short headline
description: string, // Detailed context
options?: Array<{ // Custom options (defaults to Approve/Reject)
id: string,
label: string,
description?: string
}>,
allow_freeform?: boolean, // Allow user to type custom text
related_task_id?: string,
priority?: 'normal' | 'high' | 'urgent'
}
}
// Returns: { status: 'ok', approved: boolean, selected_option: string, comment: string }When to use: Approval requests, design decisions, choosing between approaches, anything requiring user input or decision.
Flow: agent.executeTool('request_user_approval') → attentionController.setWaitingForApproval(true) → HITLService.requestApprovalAndWait(options) → notification + WebSocket → NotificationBell renders options → user responds → HITLService.respondToApproval(selectedOption) → promise resolves → agent receives result
The attention controller uses APPROVAL_WAIT_TIMEOUT_MS (24h) instead of the normal 10-minute backstop while waiting for approval, preventing the mailbox item from being requeued prematurely.
The system prompt includes scenario-specific guidance on which tool to use:
| Situation | Tool |
|---|---|
| Status report, progress update, FYI alert | notify_user (appears in chat, user may reply) |
| Task completed notification | notify_user with related_task_id |
| Need user to approve/reject something | request_user_approval (default options) |
| Need user to choose between approaches | request_user_approval with custom options |
| Need user freeform input | request_user_approval with allow_freeform: true |
| Want to discuss interactively | Mention user via task/requirement comment |
| Need to review past execution details | recall_activity (list activities or get logs) |
When multiple messages for the same entity arrive before the agent can process them, the mailbox merges them at enqueue time to prevent redundant processing:
| Group | Dedup Key | Eligible Types |
|---|---|---|
| Task comments | payload.taskId |
task_comment |
| Requirement comments | payload.requirementId |
requirement_comment |
| Channel messages | payload.extra.channelKey |
a2a_message (includes dm:a2a:* DM channels and group:* channels) |
Why status updates are excluded: task_status_update and requirement_update represent distinct state transitions with different processing semantics. Merging a "task blocked" notification with a "task resumed" notification would lose critical state information. Only comments — which are additive human/agent text — are safe to merge.
When a new item matches an existing queued (not yet processing) item in the same dedup group:
- The new item's
contentis appended to the existing item (separated by\n\n---\n\n) - The existing item's
summarygains a(+1)suffix - The new item is not inserted into the queue — the existing item serves both
- The merge is logged for traceability
- For channel messages: the
messagesarray in the payload is populated with structured per-sender entries
This prevents scenarios where 5 rapid-fire comments on the same task each trigger separate LLM calls. Instead, the agent sees one consolidated item with all 5 comments.
Group chat and A2A messages from the same channelKey are coalesced both at enqueue time and during pre-triage consolidation. The merged item carries a messages array providing structured context:
payload.messages: Array<{
senderId?: string;
senderName: string;
content: string;
timestamp: string;
}>After dequeuing a non-user item, the attention loop pauses for MAILBOX_COALESCE_WINDOW_MS (default 200ms) before starting triage. This gives rapid-fire messages time to arrive and merge via enqueue-time dedup, reducing redundant processing for burst scenarios (e.g., multiple agents posting to the same group chat within milliseconds).
Execution-trigger safety: Items with extra.triggerExecution (task execution triggers) are never merged — neither as the incoming item nor as the merge target. Execution items carry critical callbacks (onLog, cancelToken, taskProjectContext) in their extra field that would be lost during a content merge. They must always remain standalone queue entries.
Enqueue-time dedup handles items that arrive while the queue is idle. The attention controller's R2 (same-task comment merge) and R3 (same-requirement comment/update merge) handle items that arrive while the agent is focused — merging them into the current work if they relate to the same entity.
Agent-generated comments on tasks and requirements carry an activityId linking them to the execution context that produced them. This enables users to expand a comment in the UI and see the full execution log — every tool call, LLM turn, and reasoning step that led to the comment.
Agent executing activity (activityId = "act-agent1-1234...")
└─ calls task_comment / requirement_comment tool
└─ tool reads getCurrentActivityId() → "act-agent1-1234..."
└─ passes activityId to postTaskComment() / postRequirementComment()
└─ persisted in task_comments.activity_id / requirement_comments.activity_id
Both task_comments and requirement_comments tables have an activity_id column (nullable, TEXT). Human-authored comments have activity_id = NULL.
The CommentBubble component checks for activityId on agent comments. When present, a "View log" button appears on hover. Clicking it fetches execution logs via GET /api/agents/:id/activity-logs?activityId=... and renders them inline using the FullExecutionLog component.
The frontend Agent Mind view supports filtering by both category and status:
| Status | Color Indicator | Description |
|---|---|---|
queued |
Amber | Waiting in queue |
processing |
Blue (pulse) | Currently being processed |
completed |
Green | Successfully processed |
merged |
Blue | Absorbed into another item |
deferred |
Purple | Postponed for later |
dropped |
Red | Discarded |
Both category and status filters are passed as query parameters to the mailbox API endpoint.
After a server crash or restart, mailbox items that were in processing status at the time of shutdown become stale — the in-flight LLM call and callbacks are lost. These items appear as permanent zombies in the mailbox history.
On agent startup, AgentMailbox.recoverStaleItems() is called immediately after persistence is wired. It delegates to MailboxPersistence.markStaleProcessingAsDropped(agentId), which executes:
UPDATE mailbox_items SET status = 'dropped' WHERE agent_id = ? AND status = 'processing'This is safe because:
resumeInProgressTasks()already re-creates execution items for any tasks that need to continue.- The stale items' callbacks (
onLog,cancelToken, etc.) are garbage-collected references that cannot be resumed. - Marking as
dropped(notcompleted) preserves the audit trail — these items did not complete successfully.
After all queued items are restored from the database, recoverStaleItems() runs deduplicateQueue() to collapse redundant entries that accumulated before the restart:
- Heartbeat collapse: Multiple queued heartbeats are reduced to a single entry (the most recent). Older heartbeats are marked
dropped. - Comment merging:
task_commentitems with the sametaskIdare merged (content appended, summary updated with(+1)). Same forrequirement_commentbyrequirementId. - Priority escalation: When merging, the survivor inherits the highest priority of all merged items.
- Execution-trigger safety: Items with
extra.triggerExecutionare never merged — they carry critical callbacks that must remain standalone.
This prevents scenarios where a restart causes the agent to process 10 redundant heartbeats or 5 separate comment notifications for the same task.
1. wireMailboxPersistence(agentId) — sets save/updateStatus/markStaleProcessingAsDropped/loadQueued
2. mailbox.recoverStaleItems() — drops stale processing, restores queued, deduplicates
3. resumeInProgressTasks() — re-creates execution items for active tasks
When a task is retried (retryTaskFresh) or a scheduled task is reset for rerun (resetTaskForRerun), any queued informational task_status_update items for that task are dropped from the mailbox. This prevents stale notifications (e.g., "Task blocked" from before the retry) from being processed alongside the fresh execution.
Only task_status_update items that meet all conditions:
- Status is
queued(not yet processing) sourceTypeistask_status_updateextra.triggerExecutionis not set (execution-trigger items are real work, never dropped)taskIdmatches the retried task
task_comment— genuine human/agent interactionsmention— explicit @mention notificationsa2a_message— inter-agent messages- All items already in
processingstate
AgentMailbox.dropStatusUpdatesByTaskId(taskId) iterates the queue in reverse, splicing matching items and marking them as dropped in persistence. Called from retryTaskFresh() and resetTaskForRerun() in TaskService, before the fresh execution is enqueued.
Each Agent creates its own private EventBus instance. The AgentManager has a separate manager-level EventBus. External listeners (e.g., WebSocket broadcast handlers in start.ts) register on the manager's EventBus.
To bridge the gap, AgentManager.forwardAgentEvents() is called when each agent is created or restored. It subscribes to key events on the agent's private bus and re-emits them on the manager's bus:
Agent's Private EventBus Manager's EventBus start.ts (WS broadcast)
───────────────────── ────────────────── ───────────────────────
agent:activity-log ──────────► agent:activity-log ──────► Persist to main session DB
agent:activity_log ──────────► agent:activity_log ──────► Stream to frontend Activity tab
agent:started ──────────► agent:started ──────► agent:started WS event
agent:stopped ──────────► agent:stopped ──────► agent:stopped WS event
agent:paused ──────────► agent:paused ──────► agent:paused WS event
agent:resumed ──────────► agent:resumed ──────► agent:resumed WS event
agent:focus-changed ──────────► agent:focus-changed ──────► agent:focus WS event
agent:message ──────────► agent:message
task:completed ──────────► task:completed ──────► task update WS event
task:failed ──────────► task:failed ──────► task update WS event
mailbox:new-item ──────────► mailbox:new-item ──────► agent:mailbox WS event
attention:decision ──────────► attention:decision ──────► agent:decision WS event
attention:state-changed ──────► attention:state-changed ──► agent:attention WS event
attention:triage ──────────► attention:triage ──────► agent:triage WS event
Events emitted directly on the manager's bus (no forwarding needed):
agent:created— emitted byAgentManager.createAgent()/restoreFromDB()agent:removed— emitted byAgentManager.removeAgent()system:*— emitted byAgentManagerglobal operations
The agent's private bus provides internal encapsulation — the agent, its mailbox, and its attention controller communicate without coupling to the manager. The manager's bus provides a single subscription point for infrastructure concerns (persistence, WS broadcast, monitoring).
Each agent has a main session — a persistent chat session that serves as the agent's chronological activity log. Every mailbox item the agent processes (except human_chat) generates a concise activity summary in this session.
Without the main session, the agent loses narrative continuity across processing contexts. For example: a user creates a task via chat, the task completes via heartbeat-triggered execution, but the agent's chat session has no record of the completion. The main session bridges this gap by recording all mailbox-driven activity.
Agent processes mailbox item
└─ finally block in processMailboxItemInternal()
└─ buildActivityOutcome(item) → outcome string
└─ injectActivityToMainSession({type, summary, outcome, mailboxItemId})
├─ memory.appendMessage() → in-memory context for next LLM turn
└─ eventBus.emit('agent:activity-log', {agentId, sessionId, message, metadata})
└─ [forwarded to manager's EventBus]
└─ start.ts listener:
├─ chatSessionRepo.getOrCreateMainSession(agentId)
├─ chatSessionRepo.appendMessage() → DB persistence
├─ chatSessionRepo.updateLastMessage() → session sort order
└─ ws.broadcastProactiveMessage() → real-time frontend update
[ACTIVITY: <sourceType>] <summary> → <outcome>
Examples:
[ACTIVITY: heartbeat] Heartbeat check-in → heartbeat processed[ACTIVITY: callback_result] Background process succeeded: npm test → callback processed[ACTIVITY: review_request] Review task "Fix login bug" → reviewed[ACTIVITY: task_status_update] Task assigned: implement API → executed
Activity log entries (marked with activityLog: true metadata) are hidden from the chat interface and visible only in the Agent Profile Mind tab. This reduces noise in the chat while keeping full traceability in the agent's profile.
Exception — notify_user and escalation: These messages bypass injectActivityToMainSession entirely and are instead injected as regular chat messages via their own agent:notify-user and agent:escalation event handlers. notify_user messages carry { notifyUser: true, priority, taskId?, requirementId? } metadata (not activityLog), and their content includes an embedded <!-- notify_context --> comment with task/requirement references. They render as normal agent chat bubbles, allowing users to see and reply to them directly. The embedded context ensures the agent retains awareness of the notification's origin when processing the user's reply.
When the user opens an agent's chat, the frontend loads sessions via getSessionsByAgent(), which returns the main session first (sorted by is_main DESC). The main session's messages include both user conversations and notify/escalation messages, displayed chronologically.
Agent responses include a <<HANDLE_COMPLETE>> completion marker to detect abnormal termination. This marker is:
- Required in the agent's prompt instructions for non-chat processing
- Stripped from all output before display (streaming
text_delta, SSE segment fallback, heartbeat daily log) - Detected by
detectAbnormalCompletion()— if absent, the mailbox item is requeued for retry (background types), except two cases that complete without retry to avoid duplicating side effects: (a) the marker is still missing after an in-session continuation was already attempted, and (b) a user-interaction item where the user already saw partial output.
The two "complete without retry" cases above (and tool failures / MEMORY.md write
refusals) currently resolve near-silently — the user has no clear signal the turn did not
finish cleanly.
- Behavior: when a turn completes without a marker after continuation (or a tool returns
a structured error, or a memory write is refused), the agent emits a structured event
(
incomplete/tool_error) and an activity-log entry. Retry semantics are unchanged — this adds visibility only. - Invariants: each condition emits exactly one corresponding event; no additional retries are triggered by making it visible. See the mailbox-item terminal states in STATE-MACHINES.md and the event contract in STREAMING-AND-REATTACH.md §4.1.
- Testing (
packages/core/test/attention.test.ts— "A3: emits agent:incomplete…"): a marker-missing completion emits exactly oneagent:incompleteevent, adds no retry, and completes the item. - Status: implemented (
AttentionController.emitIncomplete, emitted from the complete-without-retry and max-retries-exhausted terminals; consumers can forward it to the activity log / SSE per STREAMING-AND-REATTACH.md §4.1).
When an agent dequeues a mailbox item and additional items remain in the queue, the Attention Controller triggers an LLM-driven triage phase before processing. This ensures the agent considers all pending work holistically rather than blindly following priority order.
Before triage (or processing), the system automatically consolidates queued items that share the same taskId or requirementId — regardless of sourceType. This is a cross-type merge: a task_status_update, a2a_message, mention, and task_comment all referencing tsk_abc123 are collapsed into a single rich-context item.
This differs from enqueue-time dedup (which only merges same-type comments):
- Enqueue-time:
task_comment+task_commentfor same task → merged (fast, prevents accumulation) - Pre-triage:
task_comment+a2a_message+task_status_updatefor same task → consolidated (comprehensive, gives agent full entity context)
The headItem is temporarily put back into the queue so it participates in consolidation, then re-dequeued.
The triage system implements a three-tier cognitive model inspired by Dual Process Theory (Kahneman, 2011):
- Tier 0 (Reflexive / System 1): Queue depth = 0 after consolidation, or priorities clearly separate items → no LLM call needed. Process immediately.
- Tier 1 (Quick Classification / System 1+): Interrupt arrives during focused work, heuristic says "continue" but LLM may disagree → single LLM call returning one word (
continue/preempt/cancel/merge/defer). - Tier 2 (Full Deliberation / System 2): Multiple items with overlapping priorities AND
performDeliberationconfigured → full agent session with complete tool access and memory.
dequeueAsync() → headItem
│
├─ queue empty? → TIER 0: record 'pick' → process(headItem)
│
└─ queue non-empty?
├─ CONSOLIDATION: putBack(headItem) → consolidateByEntity() → dequeue()
│ (items sharing same taskId/requirementId merged cross-type)
│
├─ queue empty after consolidation? → TIER 0
│
└─ priorities clearly separate? → TIER 0
│
└─ ambiguous priorities AND (performDeliberation OR triageJudge)?
│
├─ performDeliberation available?
│ → TIER 2: Full-session deliberation (scenario='deliberation')
│ ├─ Agent deliberates AS ITSELF with full identity
│ ├─ Access to memory, recall_activity, task tools
│ ├─ Can handle simple items INLINE (notify, comment, message)
│ ├─ Calls complete_deliberation tool with decision
│ ├─ Apply inline-completed / defer / drop / choose
│ └─ Working memory updated (`deliberation` key from situational awareness)
│
└─ triageJudge only (fallback)?
→ Mini triage loop (up to TRIAGE_MAX_TOOL_ITERATIONS rounds)
├─ Build prompt with candidates + context
├─ LLM calls read-only tools to gather context
├─ Returns JSON: { processItemId, deferItemIds, dropItemIds }
└─ Apply decisions
When delegate.performDeliberation is available (always in production), the attention controller delegates triage to a full agent session:
- The agent receives all queued items formatted as a
[DELIBERATION MODE]prompt. - It uses
handleMessage()withscenario: 'deliberation'— the same code path as chat/task but with restricted tool access (DELIBERATION_ALLOWED_TOOLS). - The agent deliberates as itself (full system prompt, identity, memory access).
- It can handle simple items inline (e.g., send a quick notification, post a comment).
- It calls
complete_deliberationwith its structured decision. - The attention controller applies the decision: mark inline items completed, defer/drop others, choose the focus item.
Deliberation is atomic: yield points are suppressed during deliberation (new mail sets the interrupt signal but is not evaluated until deliberation completes).
Defined by DELIBERATION_ALLOWED_TOOLS in @markus/shared:
| Category | Tools |
|---|---|
| Context gathering | task_list, task_get, requirement_list, requirement_get, list_projects, team_list, team_status, recall_activity, memory_search, memory_search_longterm |
| Inline communication | notify_user, task_comment, requirement_comment, agent_send_message, agent_send_group_message, agent_create_group_chat, agent_list_group_chats |
| Mailbox management | check_mailbox, defer_mailbox_item, drop_mailbox_item, prioritize_mailbox_item |
| Working memory | update_working_memory, clear_working_memory |
| Decision output | complete_deliberation |
Excluded: task_create, task_update, requirement_propose, code/shell tools, spawn_subagent. These are heavy side-effect tools that belong in the processing phase.
In addition to the batch decision via complete_deliberation, agents can manage
individual mailbox items using dedicated tools:
| Tool | Available In | Purpose |
|---|---|---|
check_mailbox |
All scenarios | Read-only queue inspection |
defer_mailbox_item |
Deliberation + focused processing | Postpone an item |
drop_mailbox_item |
Deliberation + focused processing | Discard stale item |
prioritize_mailbox_item |
Deliberation only | Re-prioritize item |
update_working_memory |
All scenarios | Update situational awareness |
clear_working_memory |
All scenarios | Clear stale awareness |
Safety: human_chat items are protected — they cannot be deferred, dropped,
or reprioritized by tool calls.
Relationship to complete_deliberation: The individual tools take immediate
effect. complete_deliberation handles remaining items in bulk. They are additive.
interface DeliberationResult {
processItemId: string; // Primary item (backward compat)
processItemIds?: string[]; // Batch: multiple items to process together
batchContext?: string; // Synthesis/instruction for batch processing
deferItemIds: string[]; // Postpone for later
dropItemIds: string[]; // Discard (stale/redundant)
inlineCompletedIds: string[]; // Already handled during deliberation
reasoning: string;
situationalAwareness?: string; // Agent-authored metacognitive summary
memoryUpdates?: Array<{ // Memory operations applied after deliberation
type: 'working' | 'longterm';
key: string;
content: string;
}>;
}When processItemIds contains more than one item, the attention controller dequeues all of them and composes their content into a single LLM session. This is efficient for:
- Multiple
a2a_messageitems from the same channel (already partially merged by dedup) - Related notifications about the same project/topic
- Multiple low-priority items that can be acknowledged together
All batch items share one LLM call. If processing is interrupted (preempt/cancel/timeout), all batch items are requeued.
Deliberation can produce memoryUpdates — both working memory (volatile, per-session) and long-term memory (curated, persisted to MEMORY.md). This enables the agent to:
- Record team decisions observed in merged messages
- Note user preferences inferred from multiple interactions
- Build context continuity across processing cycles
Tools memory_save and memory_update_longterm are also available during deliberation for immediate effect.
### Working Memory Injection
The agent maintains a **keyed working memory store** (`workingMemory` Map) that
replaces the former `currentCognition` string. Each entry has:
- A key (agent-chosen label, e.g., "deliberation", "task-priorities")
- Content text
- Update timestamp
Working memory is populated from three sources:
1. **Deliberation**: `situationalAwareness` → key `"deliberation"`
2. **Triage**: reasoning → key `"triage-decision"`
3. **Agent tools**: `update_working_memory` / `clear_working_memory`
All entries are injected into every system prompt as `## Working Memory` with
age labels per entry. The agent decides what to keep, update, or expire.
### Mini Triage Loop (Fallback)
When `performDeliberation` is unavailable, the system falls back to the legacy mini triage loop:
- Triage LLM uses the agent's name and role in its system prompt.
- Can call tools from `TRIAGE_ALLOWED_TOOLS` (including `recall_activity`, `memory_search`, `memory_search_longterm`) up to `TRIAGE_MAX_TOOL_ITERATIONS` (6) rounds.
- Returns a structured JSON decision (same as `TriageResult`).
### Key Methods
| Method | Location | Purpose |
|--------|----------|---------|
| `performDeliberation()` | `agent.ts` | Full-session deliberation via handleMessage(scenario='deliberation') |
| `applyDeliberationResult()` | `attention.ts` | Applies inline/defer/drop/choose from deliberation |
| `complete_deliberation` (tool) | `agent.ts` executeTool | Captures structured decision during deliberation |
| `applyTriageResult()` | `attention.ts` | Applies defer/drop/choose from mini triage (fallback) |
| `performTriage()` | `attention.ts` | Mini triage loop (fallback path) |
| `buildTriagePrompt()` | `attention.ts` | Constructs the triage prompt with candidates + context |
| `consolidateByEntity()` | `mailbox.ts` | Merges items sharing same taskId/requirementId (cross-type) |
| `updateCognitionFromDeliberation()` | `agent.ts` | Writes deliberation `situationalAwareness` into `workingMemory` under key `"deliberation"` |
| `evaluateWithLLMFallback()` | `attention.ts` | Heuristic-first interrupt decision with optional LLM fallback |
### Triage Prompt Identity
Both deliberation (Tier 2) and the mini triage loop (fallback) use first-person perspective: "You are {agentName}." The agent deliberates **as itself** with its role context, not as an external "attention manager."
---
## 22. Deferred Item Auto-Resume
Deferred mailbox items are automatically resurfaced when the agent is idle:
- `resurfaceDue()` is called at the top of each attention loop idle cycle (before `dequeueAsync`) and after `recoverStaleItems` on startup.
- Items where `deferredUntil <= now` or `deferredUntil` is undefined (deferred without a time = resume on next idle) are re-enqueued.
- Deferred items are loaded from persistence via `loadDeferred(agentId)`.
## 23. Task Status Update Processing
`task_status_update` items are **informational only** (`invokesLLM: false`). The side-effect system in `updateTaskStatus()` handles all real actions automatically:
- **→ `in_progress`**: Auto-starts task execution
- **Leaving `in_progress`**: Cancels running execution
- **→ `review`**: Notifies reviewer (agent via mailbox, or human via approval request)
- **Terminal states**: Checks and unblocks dependent tasks
Agents do NOT need to take action on these notifications — they serve as episodic memory and triage decision context. Agents should NOT send A2A messages to duplicate what the side-effect system already does.
## 24. Cognitive Science Foundation
The three-tier triage model (§21) is grounded in established cognitive science:
| Theory | Application in Markus |
|--------|----------------------|
| **Dual Process Theory** (Kahneman, 2011) | Tier 0/1 = System 1 (fast, automatic); Tier 2 = System 2 (slow, deliberative) |
| **Situational Awareness** (Endsley, 1995) | Full deliberation achieves Level 2+3 SA (comprehension + projection) via memory access |
| **Working Memory / Episodic Buffer** (Baddeley, 2000) | `recall_activity` and `memory_search` serve as the episodic buffer linking current stimuli to long-term memory |
| **Metacognition** (Flavell, 1979) | `situationalAwareness` field in `DeliberationResult` enables agent self-monitoring |
| **Prospective Memory** (Einstein & McDaniel, 2005) | During deliberation, agents can check pending commitments and proactively notify users |
| **Cognitive Load Theory** (Sweller, 1988) | Unified identity in deliberation eliminates task-switching cost between "triage assistant" and "agent self" |
### Design Rationale
- **Why not always use Tier 2?** Cost and latency. Tier 2 deliberation costs 5-15K tokens vs 0 for Tier 0. Now with `TRIAGE_BACKLOG_THRESHOLD = 2`, deliberation fires whenever 2+ items are queued (non-user head). The cost increase is acceptable because:
- (a) most single-item processing still takes the fast path,
- (b) deliberation with 2-3 items is much cheaper than with 5+, and
- (c) the agent can now use `drop_mailbox_item` during deliberation to clean up before committing to expensive processing.
- **Why allow inline handling?** Cognitive science shows humans handle simple items (quick email replies) during triage rather than queuing them separately. This reduces total cognitive load and latency.
- **Why suppress yield points during deliberation?** Deliberation is a metacognitive process — interrupting it would break the agent's reasoning coherence (analogous to interrupting someone mid-thought during planning).
- **Why inject working memory into future prompts?** This implements **metacognitive regulation** — keyed summaries (deliberation, triage, agent-authored entries) persist across processing contexts, maintaining behavioral consistency.
---
## 25. Proactive Messaging Capabilities
### Can agents proactively send messages?
**Yes.** Agents can initiate communication across multiple channels, including targeted user notifications.
### 25.1 User DM (1:1 Chat)
**Tool**: `notify_user`
| Capability | Status |
|-----------|--------|
| Send proactive message to user | **Yes** — appears in agent's main chat session |
| Message shows in chat UI | **Yes** — as regular assistant message (not hidden as activity log) |
| User can reply in chat | **Yes** — reply triggers `human_chat` mailbox item |
| Notification bell | **Yes** — `HITLService.notify()` creates persistent notification |
| Target specific user by ID | **Yes** — `target_user_id` parameter (optional) |
| Fallback when no target specified | Uses `currentInteractingUserId` (last human who chatted), then team owner |
| Initiate first-ever conversation with user | **Yes** — via `target_user_id` during deliberation or heartbeat |
**Flow**:
Agent calls notify_user(title, body, target_user_id?) → Build context suffix: → memory.appendMessage(formattedMsg + contextSuffix) — in-memory session → eventBus.emit('agent:notify-user', { targetUserId, taskId, requirementId, ... }) → start.ts handler: → chatSessionRepo.getOrCreateMainSession(agentId, targetUserId ?? ownerId) → chatSessionRepo.appendMessage(msg, metadata: { notifyUser, priority, taskId, requirementId }) → ws.broadcastProactiveMessage(metadata: { notifyUser, taskId, requirementId }) — scoped to target user → hitlService.notify() — notification bell entry
**Context preservation**: When the user replies to a notification, `restoreSessionFromHistory()` loads the message content (including the `<!-- notify_context -->` comment). The agent's `chat` scenario prompt instructs it to use these references to retrieve full context before responding.
### 25.2 Group Chat
**Tool**: `agent_send_group_message`
| Capability | Status |
|-----------|--------|
| Send message to team group | **Yes** — `channel_key: "group:<teamId>"` |
| Send message to custom group | **Yes** — `channel_key: "group:custom:<id>"` |
| Message visible to all channel members | **Yes** — persisted + WebSocket broadcast |
| Triggers other agents in channel | **Yes** — enqueues `a2a_message` to peer agents |
| Create new group chat | **Yes** — `agent_create_group_chat` tool |
| Available during deliberation | **Yes** — in `DELIBERATION_ALLOWED_TOOLS` |
### 25.3 Agent-to-Agent (A2A)
A2A messages route through persistent DM channels (`dm:a2a:{sorted_ids}`) — see §11.1.
| Tool | Capability |
|------|-----------|
| `agent_send_message` | Direct message to another agent via DM channel |
| `agent_broadcast_status` | Status update to all colleagues (lightweight, no LLM) |
| `agent_delegate_task` | Formal task delegation with protocol |
### 25.4 Proactive Messaging During Deliberation
During full-session deliberation (Tier 2), agents can handle simple items inline by calling communication tools directly:
Deliberation session starts (5 items queued) → Agent calls memory_search to check commitments → Agent calls notify_user(target_user_id="usr_alice", ...) — handles item #3 inline → Agent calls task_comment(...) — handles item #5 inline → Agent calls complete_deliberation({ process_item_id: "item_1", inline_completed_ids: ["item_3", "item_5"], defer_item_ids: ["item_4"], drop_item_ids: ["item_2"], reasoning: "Item 1 is an urgent review request; handled notifications inline" })
This collapses what would have been 5 separate triage+process cycles into a single deliberation + 1 processing cycle.
---
## 26. Future Work
- **Proactive messaging scheduler** *(implemented)*: Agents schedule time-based follow-ups via `schedule_wakeup` (one-shot or recurring); a coarse per-agent sweep fires due wakeups into the mailbox. See §11.3.
- **Cross-agent priority coordination**: Allow a manager agent to influence subordinate agents' mailbox priorities.
- **Decision pattern learning**: Use long-term decision history to adaptively tune heuristic thresholds.
- **Deliberation cost optimization**: Implement adaptive deliberation depth — use cheaper models or shorter budgets for simple multi-item scenarios, full budget only for genuinely complex triage.
- **Working memory persistence**: Persist keyed working memory across agent restarts (currently volatile).
- **Adaptive deliberation model selection**: Use cheaper models for simple 2-item queues; reserve fuller budgets for genuinely complex triage.
- **Recursive deliberation**: Allow deliberation to trigger sub-deliberation for complex multi-step planning (with depth limits).