@turmind/halo 1.4.6 → 1.5.1-alpha
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/README.md +0 -1
- package/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/160.c52d3d6cf7bc8286.js +1 -0
- package/admin-out/_next/static/chunks/220.e250bbbf65d24094.js +1 -0
- package/admin-out/_next/static/chunks/362.a0c58d3bf9b1300b.js +1 -0
- package/admin-out/_next/static/chunks/449.c4b881b9ebdc556e.js +1 -0
- package/admin-out/_next/static/chunks/613.fb09e55e0a61d3fe.js +1 -0
- package/admin-out/_next/static/chunks/73.05abce37d312e880.js +1 -0
- package/admin-out/_next/static/chunks/803-7128009ee0e1c10e.js +1 -0
- package/admin-out/_next/static/chunks/839.a2729fd33013d763.js +1 -0
- package/admin-out/_next/static/chunks/868.bbb91afd48c9eb58.js +1 -0
- package/admin-out/_next/static/chunks/892.a35118e09d8d8efd.js +1 -0
- package/admin-out/_next/static/chunks/989-7420ee650083df43.js +1 -0
- package/admin-out/_next/static/chunks/app/page-fa5e935dbaa74165.js +1 -0
- package/admin-out/_next/static/chunks/webpack-1a264efd965db7ab.js +1 -0
- package/admin-out/_next/static/css/b8ffb5202b5a6619.css +3 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +5 -5
- package/bundled-docs/design/agent.md +8 -7
- package/bundled-docs/design/architecture.md +9 -5
- package/bundled-docs/design/canvas-extensions.md +183 -0
- package/bundled-docs/design/cron.md +1 -1
- package/bundled-docs/design/express-self.md +9 -7
- package/bundled-docs/design/feishu.md +3 -1
- package/bundled-docs/design/observability.md +2 -0
- package/bundled-docs/design/prompt-system.md +6 -10
- package/bundled-docs/design/relay.md +22 -16
- package/bundled-docs/design/session.md +38 -26
- package/bundled-docs/design/slack.md +2 -2
- package/bundled-docs/design/storage.md +5 -5
- package/bundled-docs/design/telegram.md +4 -2
- package/bundled-docs/design/wechat.md +7 -5
- package/bundled-docs/design/wecom.md +2 -2
- package/bundled-docs/design/ws.md +29 -22
- package/bundled-docs/dev/add-model-provider.md +21 -18
- package/bundled-docs/dev/api.md +25 -24
- package/bundled-docs/dev/deploy.md +39 -9
- package/bundled-docs/dev/desktop-packaging.md +18 -8
- package/bundled-docs/dev/env.md +2 -3
- package/bundled-docs/dev/previews.md +9 -1
- package/bundled-docs/dev/tools.md +17 -82
- package/bundled-docs/guide/channels/wechat.md +1 -1
- package/bundled-docs/guide/chat.md +4 -2
- package/bundled-docs/guide/cli.md +0 -1
- package/bundled-docs/guide/delegation-and-access.md +1 -2
- package/bundled-docs/guide/workspace.md +11 -0
- package/bundled-docs/requirements/chat.md +11 -8
- package/bundled-docs/requirements/command.md +1 -12
- package/bundled-docs/requirements/editor.md +10 -0
- package/bundled-docs/requirements/settings.md +9 -0
- package/dist/index.js +2168 -1199
- package/package.json +2 -2
- package/templates/agents/deep-executor/agent.yaml +0 -3
- package/templates/agents/default/AGENT.md +0 -17
- package/templates/agents/default/agent.yaml +0 -1
- package/templates/agents/executor/agent.yaml +1 -1
- package/templates/models/aws-bedrock-claude-invoke.yaml +31 -0
- package/templates/models/aws-bedrock-mantle.yaml +42 -7
- package/templates/models/aws-bedrock-openai.yaml +53 -17
- package/templates/prompts/root/DELEGATION.md +58 -0
- package/templates/skills/extension/SKILL.md +70 -0
- package/templates/skills/extension/templates/ext.sh +112 -0
- package/templates/skills/halo/SKILL.md +3 -0
- package/templates/skills/send-file/SKILL.md +1 -1
- package/admin-out/_next/static/chunks/220.1ed70c874686122f.js +0 -1
- package/admin-out/_next/static/chunks/23-092ac3901411582e.js +0 -1
- package/admin-out/_next/static/chunks/362.5970ef15ec3d7b15.js +0 -1
- package/admin-out/_next/static/chunks/449.a403f7ad18054e3f.js +0 -1
- package/admin-out/_next/static/chunks/613.f314fc12e05a566c.js +0 -1
- package/admin-out/_next/static/chunks/73.a4617a551a8e176c.js +0 -1
- package/admin-out/_next/static/chunks/767.16a0b33b6f5e7965.js +0 -1
- package/admin-out/_next/static/chunks/839.e381287756453ffe.js +0 -1
- package/admin-out/_next/static/chunks/868.cc7fd84c9f2fd711.js +0 -1
- package/admin-out/_next/static/chunks/892.92b2ed455683b49a.js +0 -1
- package/admin-out/_next/static/chunks/989-6a0ec35b8ae2cb39.js +0 -1
- package/admin-out/_next/static/chunks/app/page-bf9c0b21ae431c92.js +0 -1
- package/admin-out/_next/static/chunks/webpack-ca926228f8cba1ff.js +0 -1
- package/admin-out/_next/static/css/c0ea31cd787f7aa7.css +0 -3
- package/bundled-docs/design/goal-mode.md +0 -144
- /package/admin-out/_next/static/{qYSILjqBjfgTggO0T8E7T → gRsZxPP4kTOmCtkHhJIZB}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{qYSILjqBjfgTggO0T8E7T → gRsZxPP4kTOmCtkHhJIZB}/_ssgManifest.js +0 -0
|
@@ -98,15 +98,17 @@ interface AgentSession {
|
|
|
98
98
|
agent: ModelRuntime
|
|
99
99
|
description: string
|
|
100
100
|
output: string // all assistant text of the current turn (reset per turn)
|
|
101
|
-
finalOutput: string // wrap-up text only (stopReason !== 'tool_use') — feeds auto-report /
|
|
101
|
+
finalOutput: string // wrap-up text only (stopReason !== 'tool_use') — feeds auto-report / relay report
|
|
102
102
|
lastActivityAt: string | null // ISO of the turn's latest text / tool_call / tool_result event; null at turn start — surfaced by get_session_output
|
|
103
103
|
turnError: string | null // set when the turn died on an unrecoverable error — prefixes the report with an ABORTED marker
|
|
104
|
+
turnErrorKind: ModelErrorKind | null // classifyModelError kind of turnError (null exactly when turnError is); 'account' swaps the report's "resume it" hint for "fix the model config first"
|
|
104
105
|
promise: Promise<string> | null // non-null = running
|
|
105
106
|
abortController: AbortController | null
|
|
106
107
|
messageQueue: QueuedMessage[] // single unified queue: user→agent AND agent→agent
|
|
107
108
|
toolCallLog: Array<{name, inputHash}> // loop detection
|
|
108
109
|
contextConfig: { maxTokens, compressAt }
|
|
109
110
|
isCompacting: boolean
|
|
111
|
+
foldAfterCompact: string | null // queue text a Stop folded while a compact was in flight; clearCompacting lands it on the post-compact history
|
|
110
112
|
interruptRequested: boolean // soft-interrupt flag — abort after the current tool_result
|
|
111
113
|
selfKick: boolean // armed by the built-in continue_task tool; drainQueue turns it into one synthetic resume turn (one-turn lifetime)
|
|
112
114
|
resumedAfterInterrupt: boolean // this turn was started by drainQueue after an interrupt — the only state in which continue_task arms selfKick
|
|
@@ -127,8 +129,8 @@ interface AgentSession {
|
|
|
127
129
|
|
|
128
130
|
Fix (the rule: **agentId is the only identity; nothing that locates or persists may fall back to the display name**):
|
|
129
131
|
- `processSessionEvent` stamps `agentId: session.agentId` on all five bare sub-session events.
|
|
130
|
-
- `
|
|
131
|
-
- `ui-log-builder`'s three `initSubSessionLog` call sites changed `?? agentName` → `?? ''`; an empty id is harmless because `
|
|
132
|
+
- `persistLog` (the sub-session branch) no longer trusts the event-reconstructed `sub.agentId`; it resolves the authoritative id by `taskId` (in-memory session → db row, process-cached) — the same source the root branch uses. This honours the "persistent operations must not depend on in-memory rebuilt state" rule.
|
|
133
|
+
- `ui-log-builder`'s three `initSubSessionLog` call sites changed `?? agentName` → `?? ''`; an empty id is harmless because `persistLog` re-resolves the real id by `taskId`.
|
|
132
134
|
|
|
133
135
|
### Session ID format
|
|
134
136
|
|
|
@@ -147,11 +149,11 @@ Hierarchical encoding: `root_id>child_segment>grandchild_segment`.
|
|
|
147
149
|
|
|
148
150
|
Both true → sets `stoppedAt` + emits `agent_done` + `querySession`s the result back to the parent. The parent's `querySession` clears its own `stoppedAt`, handles the report, and may trigger its own `tryReportToParent` — bubbling up.
|
|
149
151
|
|
|
150
|
-
**Abnormal-termination marker**: when the reported turn was killed by an unrecoverable error (retry budget exhausted / account-level failure), the session's `turnError` field holds the error text and the auto-report is prefixed with an explicit `[SUB-AGENT ABORTED: … Error: <text> … Re-dispatch with query_session(…) to resume.]` block. Without it the parent LLM consumed mid-turn fragments (or a literal "(no output)") as completed reports — the cron-era "sub-agent reported without finishing" incident, root-caused to a Bedrock h2 hang (`TimeoutError: http2 request did not get a response`, now also in the transient-transport retry list). Prefix, don't suppress: skipping the report would leave the parent waiting forever, the partial trace has diagnostic value, and `stoppedAt` is stamped as usual so the child never shows as falsely running. The marker is prepended *before* the truncation cap so it can never be sliced off.
|
|
152
|
+
**Abnormal-termination marker**: when the reported turn was killed by an unrecoverable error (retry budget exhausted / account-level failure), the session's `turnError` field holds the error text and the auto-report is prefixed with an explicit `[SUB-AGENT ABORTED: … Error: <text> … Re-dispatch with query_session(…) to resume.]` block. An **account** error (`turnErrorKind === 'account'`: 401/402/403, bad key, no balance) replaces the trailing hint with "this is a model account / credential / balance / permission problem — retrying or re-dispatching will fail the same way; fix the model configuration first (or tell the user), and do not resume it with `query_session` until then"; `RELAY TARGET ABORTED` ([relay.md](relay.md)) and goal mode's `WORKER ABORTED` get the same swap. Without it the parent LLM consumed mid-turn fragments (or a literal "(no output)") as completed reports — the cron-era "sub-agent reported without finishing" incident, root-caused to a Bedrock h2 hang (`TimeoutError: http2 request did not get a response`, now also in the transient-transport retry list). Prefix, don't suppress: skipping the report would leave the parent waiting forever, the partial trace has diagnostic value, and `stoppedAt` is stamped as usual so the child never shows as falsely running. The marker is prepended *before* the truncation cap so it can never be sliced off.
|
|
151
153
|
|
|
152
|
-
The full finally-chain order is `tryReportToParent → deliverGoalRound → deliverRelayReport → releaseSession
|
|
154
|
+
The full finally-chain order is `tryReportToParent → deliverGoalRound → deliverRelayReport → releaseSession`. `deliverGoalRound` is a retained legacy hook (goal mode's round delivery): it keys off the row's `goal_session_id` and works as before for any existing goal binding. After the parent-report check, a root whose row carries a `reply_to` back-pointer (it was dispatched from another workspace via `relay_send`) gets its wrap-up appended + sent into the caller session in that workspace (same subtree-quiet gate, `reply_to` cleared before sending — see [relay.md](relay.md)), then the session is released. Each hook decides on its own db column (`parent_id` / `goal_session_id` / `reply_to`), so the three never fire for the same session.
|
|
153
155
|
|
|
154
|
-
- **Sibling-status injection for root** (`siblingStatusSuffix`): `tryReportToParent` early-returns for root (`parentId === null`) since there's no parent to bubble up to — so root never learns whether its *other* children are still running, and the root LLM could wrap up early after consuming just one child's report. When root consumes a child report (`querySession` idle branch + `drainQueue`), a sibling-status line is appended to the message fed to the LLM. "All sub-agents done" requires **both** no sibling running in the DB (`parentId = root AND stoppedAt IS NULL`) **and** an empty in-memory `messageQueue` — a child can be stopped while its report is still queued, so the DB check alone would falsely declare completion. The reporting child needs no identity exclusion: it stamped `stoppedAt` before the report was delivered, so `stoppedAt IS NULL` already excludes it (unless re-dispatched a new task, which clears `stoppedAt` — then it correctly counts as running). The line carries per-child `created` + `last active` timestamps so a capable model can tell a freshly dispatched sibling from an original-batch leftover. (Its `[System @ <iso>]` header used to be the *only* wall-clock signal the model ever saw; since 1.1.6 every turn carries an arrival stamp — see [Message queue and drain](#message-queue-and-drain).) Mid-tier parents are excluded by design — their `tryReportToParent` bubble-up already gates them on a fully-drained subtree.
|
|
156
|
+
- **Sibling-status injection for root** (`siblingStatusSuffix`): `tryReportToParent` early-returns for root (`parentId === null`) since there's no parent to bubble up to — so root never learns whether its *other* children are still running, and the root LLM could wrap up early after consuming just one child's report. When root consumes a child report (`querySession` idle branch + `drainQueue`), a sibling-status line is appended to the message fed to the LLM. "All sub-agents done" requires **both** no sibling running in the DB (`parentId = root AND stoppedAt IS NULL`) **and** an empty in-memory `messageQueue` — a child can be stopped while its report is still queued, so the DB check alone would falsely declare completion. The reporting child needs no identity exclusion: it stamped `stoppedAt` before the report was delivered, so `stoppedAt IS NULL` already excludes it (unless re-dispatched a new task, which clears `stoppedAt` — then it correctly counts as running). The line carries per-child `created` + `last active` timestamps so a capable model can tell a freshly dispatched sibling from an original-batch leftover. (Its `[System @ <iso>]` header used to be the *only* wall-clock signal the model ever saw; since 1.1.6 every turn carries an arrival stamp — see [Message queue and drain](#message-queue-and-drain).) The same rows are also emitted to the admin as a `system` event (root only) so a reviewer can see what root was told; that UI copy has a clock-less `[System]` header (the message carries its own `timestamp`) and relative ages (`started 46m ago, last active 2m ago`, via `formatAge`) instead of the ISO stamps the model gets. Mid-tier parents are excluded by design — their `tryReportToParent` bubble-up already gates them on a fully-drained subtree.
|
|
155
157
|
|
|
156
158
|
- **interruptSession**: fire-and-forget abort of the in-flight turn — it sets `interruptRequested` so `runAgentTurn`'s unwind repairs (not errors), then aborts. It does **not** await or re-run: once the aborted turn unwinds, `runSession`'s finally sees the non-empty queue and `drainQueue` folds the queued message into one merged follow-up turn. The `interrupt_session` tool reaches this via `querySession(..., interrupt=true)` (enqueue + abort), so there is no separate re-run path or `skipRelease` bookkeeping.
|
|
157
159
|
|
|
@@ -161,17 +163,19 @@ A sub-session whose process was killed mid-run never got its `stoppedAt` written
|
|
|
161
163
|
|
|
162
164
|
**Workspace-level gate (`.halo/runtime.lock`)**: `server.lock` ownership alone is not sufficient — two servers with different `HALO_HOME` (e.g. prod + dev) each hold their own `server.lock` yet can point at the *same* workspace directory, and one server's boot reconcile would batch-stop the other's actually-live sub-sessions (the incident this gate exists for). So the reconcile additionally requires `claimWorkspaceRuntime(workspaceRoot)` (`agents/workspace-runtime-lock.ts`) — a pid marker at `<workspace>/.halo/runtime.lock` with a liveness probe. Claim fails → skip reconcile and log a warning: **prefer missing a crash-orphan cleanup over stopping another process's live sessions**. `reconcileOrphansOnBoot: true` therefore means "reconcile if the workspace claim succeeds," not "always." Lock protocol details in [storage.md](storage.md#workspace-runtime-lock); known residual: when two servers both actively use one workspace long-term, the non-owner never reconciles — its own crash orphans stay un-cleaned until the owner restarts and takes over. The run-ledger eager boot loop below deals with the *un-opened* case for the same residual: a workspace whose runtime another live process owns is skipped before a SessionManager is even built, so the rows wait for a later boot instead of freezing behind a cached non-owner instance.
|
|
163
165
|
|
|
164
|
-
|
|
166
|
+
**Shutdown** (`gracefulShutdown` in `index.ts`): for every loaded workspace, `sm.flushAll()` first lands the UI logs not yet persisted (a dirty root plus every live sub-session log — the pending 500ms debounces would die with the process), then `releaseWorkspaceRuntime` unlinks `.halo/runtime.lock` only when it holds *this* process's pid. A crash / SIGKILL leaves the file behind and takes the "pid dead → next boot takes over" path, so the release is an optimisation (a recycled pid can't read as a live holder), never a correctness dependency. The desktop app's `before-quit` (POSIX) holds the quit until the server process has actually exited (3s hard cap), so `killServer`'s SIGKILL fallback for a wedged server still runs instead of being dropped with the Electron process — otherwise a stuck server keeps the port and `server.lock`.
|
|
167
|
+
|
|
168
|
+
The constructor chain is now `reconcile orphans → sweepActiveGoals (retained legacy) → sweepInterruptedRuns` — all three gated on the same `reconcileOrphansOnBoot` + `.halo/runtime.lock` claim, so a workspace that fails the claim runs none of them.
|
|
165
169
|
|
|
166
170
|
### Run ledger — restart nudge for interrupted roots (`~/.halo/global/runs.db`)
|
|
167
171
|
|
|
168
|
-
The orphan reconcile above fixes the *children*'s `stoppedAt`; nobody told the **root** the server restarted — asked "done yet?" it still answers "waiting on reports" forever.
|
|
172
|
+
The orphan reconcile above fixes the *children*'s `stoppedAt`; nobody told the **root** the server restarted — asked "done yet?" it still answers "waiting on reports" forever. Plain roots had no restart nudge, because "which session is running right now" only ever lived in memory.
|
|
169
173
|
|
|
170
174
|
**Ledger** (`db/runs-db.ts`): a global `~/.halo/global/runs.db` (same singleton pattern as `evo.db` / `cron.db` — `setRunsDb`/`getRunsDb`, WAL), one table `running_sessions(workspace TEXT, session_id TEXT, started_at INTEGER, PK(workspace, session_id))`. `runSession` inserts the id on entry and deletes it in its `finally`, so the steady state is an **empty table** — whatever is still there at boot is exactly the set of runs the previous process died in the middle of; no pid, no liveness probe needed. Only the long-lived server writes: `SessionManager.ledgerEnabled = opts.reconcileOrphansOnBoot === true`, the same flag that gates the orphan reconcile — cli / cron / TUI / evo-wrapper runs end with their terminal and never enter the table. Writes go through a private `ledgerWrite` wrapper that try/catches and only `console.warn('[RunLedger] ledger write failed …')` on failure: a sqlite hiccup (disk full, EIO) must not drop a message or strand a session at `promise = null` without a `complete` — worst case is one stale/missing row, i.e. one spurious or missed nudge at next boot.
|
|
171
175
|
|
|
172
|
-
**Sweep** (`sweepInterruptedRuns`, `agents/run-ledger.ts`): runs from the SessionManager constructor right after `sweepActiveGoals`, under the same ownership gate. `drainRunning(workspace)` reads and deletes that workspace's rows in one transaction **before** any nudge goes out — the invariant being that a nudge's own re-dispatch inserts a fresh row, which must never be mistaken for a leftover on some future sweep. Ids are grouped by root (`id.split('>')[0]`); each root gets one append-then-send nudge (text starts `[System] The server restarted at <ISO> while you were mid-turn…`). The wording is about the root's *own* turn first: history up to the last completed tool call is intact, the in-flight call was cut off and its side effects may or may not have landed (check before repeating anything destructive), continue the task from there — don't start over, don't wait for input. Sub-agents come second (stopped-but-revivable via `query_session`; sub-sessions are not listed — the root's own transcript / `session_list` has them). The first version talked only about sub-agents, so a root killed mid tool-loop went hunting for stopped children, found old ones, and declared "nothing was interrupted" instead of resuming. Skipped per root: row missing or `archivedAt` set;
|
|
176
|
+
**Sweep** (`sweepInterruptedRuns`, `agents/run-ledger.ts`): runs from the SessionManager constructor right after the retained legacy `sweepActiveGoals`, under the same ownership gate. `drainRunning(workspace)` reads and deletes that workspace's rows in one transaction **before** any nudge goes out — the invariant being that a nudge's own re-dispatch inserts a fresh row, which must never be mistaken for a leftover on some future sweep. Ids are grouped by root (`id.split('>')[0]`); each root gets one append-then-send nudge (text starts `[System] The server restarted at <ISO> while you were mid-turn…`). The wording is about the root's *own* turn first: history up to the last completed tool call is intact, the in-flight call was cut off and its side effects may or may not have landed (check before repeating anything destructive), continue the task from there — don't start over, don't wait for input. Sub-agents come second (stopped-but-revivable via `query_session`; sub-sessions are not listed — the root's own transcript / `session_list` has them). The first version talked only about sub-agents, so a root killed mid tool-loop went hunting for stopped children, found old ones, and declared "nothing was interrupted" instead of resuming. Skipped per root: row missing or `archivedAt` set; existing goal-mode bindings (a session with a `goal` column set, or a worker whose bound goal is still `running` — the retained `sweepActiveGoals` owns those nudges, unchanged); `cron-*` ids; `internal: true` agents (synchronous read of `agent.yaml`, since the sweep runs from a sync constructor). Principle: exactly one entry point gets woken per tree — a plain tree wakes its root, a `running` goal tree (existing binding) wakes its goal session, a cron tree wakes nobody (it waits for its next fire).
|
|
173
177
|
|
|
174
|
-
**Eager boot** (`index.ts`): rather than waiting for someone to open a workspace, right after constructing `SessionManagerRegistry` (before `bootChannels`) the server calls `listRunningWorkspaces()` and for each workspace with leftover rows whose `.halo` dir still exists: `claimWorkspaceRuntime(ws)` first — if another live server owns it, `continue` (rows stay for a later boot, and no SessionManager is built, since caching a non-owner SM would freeze "not owner" for the whole process lifetime) — otherwise `registry.getOrCreate(ws)`, which fires the same constructor chain (claim → reconcile → `sweepActiveGoals` → `sweepInterruptedRuns`). An interrupted root with nobody around is exactly the one that must nudge itself without a human opening the tab first.
|
|
178
|
+
**Eager boot** (`index.ts`): rather than waiting for someone to open a workspace, right after constructing `SessionManagerRegistry` (before `bootChannels`) the server calls `listRunningWorkspaces()` and for each workspace with leftover rows whose `.halo` dir still exists: `claimWorkspaceRuntime(ws)` first — if another live server owns it, `continue` (rows stay for a later boot, and no SessionManager is built, since caching a non-owner SM would freeze "not owner" for the whole process lifetime) — otherwise `registry.getOrCreate(ws)`, which fires the same constructor chain (claim → reconcile → retained `sweepActiveGoals` → `sweepInterruptedRuns`). An interrupted root with nobody around is exactly the one that must nudge itself without a human opening the tab first.
|
|
175
179
|
|
|
176
180
|
`runs.db`/`-wal`/`-shm` join the sandbox `hidden_files` default alongside `evo.db` / `cron.db` (see [storage.md](storage.md)). Known gap carried from the design doc: `<ISO>` in the nudge is sweep time, not the actual interruption time — for a workspace that sat locked by another server before this boot could claim it, that's a real but accepted drift.
|
|
177
181
|
|
|
@@ -187,7 +191,7 @@ A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is
|
|
|
187
191
|
|
|
188
192
|
**Arrival stamp.** Both paths run the turn through `runAgentTurn`, which prepends `[<ISO-8601 UTC>] ` (e.g. `[2026-09-12T15:17:44.153Z] `) to the first text block of the model-facing input — an image-only input gets a stamp-only text block unshifted. The model has no clock; without this it couldn't tell a reply that came two days later from one that came instantly, or how long a sub-agent report took to land. Stamped **once, before the retry loop**, so every attempt (and the [multimodal 4xx degrade](#multimodal-4xx-degrade) rebuild) reuses the identical input. Agent reports therefore read `[<iso>] (from: session X)\n…` — stamp outermost, no special-casing. Only `agent.messages` changes: the UI `type:'user'` event keeps the raw text (see [Turn matching](#exchange-deletion-soft-ui--hard-raw) for the strip on compare), and the self-compact instruction / `[Conversation Summary]` injection / `tool_result` messages are never stamped.
|
|
189
193
|
|
|
190
|
-
**Final-text flag.** The agent loop marks each text block `final` when the model call ended for a reason other than `tool_use` (i.e. it is the turn's wrap-up, not the filler emitted before a tool call), and `runAgentTurn`'s `case 'text'` forwards it on the `stream` event as `AgentSessionEvent.final`. Block-send consumers gate on it: the channel responders (wechat / telegram / slack / feishu) append only `final` text, and `halo cli` prints the last root turn's `final` text as its stdout (fallback: that turn's full text). `finalOutput` (used by `tryReportToParent`
|
|
194
|
+
**Final-text flag.** The agent loop marks each text block `final` when the model call ended for a reason other than `tool_use` (i.e. it is the turn's wrap-up, not the filler emitted before a tool call), and `runAgentTurn`'s `case 'text'` forwards it on the `stream` event as `AgentSessionEvent.final`. Block-send consumers gate on it: the channel responders (wechat / telegram / slack / feishu) append only `final` text, and `halo cli` prints the last root turn's `final` text as its stdout (fallback: that turn's full text). `finalOutput` (used by `tryReportToParent`) is the same accumulation on the server side. Streaming providers additionally yield `text_delta` / `thinking_delta` events during the model call (forwarded as `stream_delta` / `thinking_delta`, UI-only); they never touch `output` / `finalOutput` — those only ever see the whole `text` event, which follows every completed call exactly as before. The one place the loop itself keeps the text deltas is the interrupted-stream landing (see [Partial streamed text lands](#conversation-repair)): a call cancelled mid-stream pushes what it had streamed as a marked assistant turn so the model's history matches what the UI showed.
|
|
191
195
|
|
|
192
196
|
**drainQueue** folds the **whole queue** into ONE merged follow-up turn per round, re-checking after each round (a fresh interrupt or a sibling's report can land mid-drain):
|
|
193
197
|
- The batch is `splice(0)`'d; agent entries keep a `(from: session X)` prefix, user entries fold raw, and all entries' `images` are merged in.
|
|
@@ -206,11 +210,13 @@ A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is
|
|
|
206
210
|
**`continue_task` — resume after interrupt.** All three interrupt paths share a say/do gap: the interrupted turn unwinds, `drainQueue` runs the new message as a fresh turn, the model answers it and `end_turn`s — it *says* "continuing" but the interrupted work never resumes (worse for sub-agents, whose answer is then auto-reported to the parent as if it were the finished result). The built-in `continue_task` tool (every agent, no params — the one truly unconditional tool: `activate_skill` is gated on `skills`, session tools on `team`; wired in `session-agent-builder`) closes it with a flag pair on `AgentSession`:
|
|
207
211
|
- **`resumedAfterInterrupt`** is snapshotted from `interruptRequested` at the top of every drained batch (before the per-batch reset) and set `false` for the opening turn. `requestSelfKick` — the tool backend — returns `not_interrupted` and sets nothing unless it is `true`, so a normal turn can't arm the flag: **kicks ≤ interrupts**, no perpetual motion.
|
|
208
212
|
- **`selfKick`** lives exactly one turn: reset at the top of every drained batch. If that reset drops a `true` (the model armed it, then got interrupted *again* before the kick fired), a one-line `[System] … flag … was reset by this interrupt … call continue_task again` note is appended to that batch's merged input — the model can't see the flag, so it's told.
|
|
209
|
-
- **Kick position**: inside `drainQueue`'s while-loop, after `runAgentTurn` and **before** the `batchBoundary` check. When `selfKick` is set, the queue is empty **and `interruptRequested` is false**, it pushes a synthetic `[System] You called continue_task: … resume it now …` user turn (`CONTINUE_TASK_KICK`) and traces it as a `type:'user'` event with `report: true` (not a `system` event — the kick is a raw user turn, so it needs a UI user row or `deleteExchange`'s UI span and `deleteRawTurn`'s raw span diverge; traced at enqueue like every queued message, routed into a sub-agent's log via `taskId`); the existing boundary `complete` then fires naturally so block channels flush the answer as its own message and the kick turn gets its own bubble. The next iteration sees `interruptRequested === false` → `resumedAfterInterrupt = false`, so the kick turn cannot re-arm. If something else landed mid-turn the kick is skipped — the next iteration's reset + note handles it (the newest message might be "stop"; the model decides with that in view, not the loop). The `!interruptRequested` gate covers the other abort path: **esc / `/interrupt` never kicks** — `interruptSession` sets the flag and aborts *without* enqueuing, so the queue check alone would have let an esc'd turn resume itself; esc means "stop what you're doing". Whenever the loop is about to exit on an empty queue the flag is cleared so it never dangles. It is deliberately **not** in `runSession`'s `finally`: `tryReportToParent` / `
|
|
210
|
-
- **Interim report
|
|
211
|
-
- **
|
|
213
|
+
- **Kick position**: inside `drainQueue`'s while-loop, after `runAgentTurn` and **before** the `batchBoundary` check. When `selfKick` is set, the queue is empty **and `interruptRequested` is false**, it pushes a synthetic `[System] You called continue_task: … resume it now …` user turn (`CONTINUE_TASK_KICK`) and traces it as a `type:'user'` event with `report: true` (not a `system` event — the kick is a raw user turn, so it needs a UI user row or `deleteExchange`'s UI span and `deleteRawTurn`'s raw span diverge; traced at enqueue like every queued message, routed into a sub-agent's log via `taskId`); the existing boundary `complete` then fires naturally so block channels flush the answer as its own message and the kick turn gets its own bubble. The next iteration sees `interruptRequested === false` → `resumedAfterInterrupt = false`, so the kick turn cannot re-arm. If something else landed mid-turn the kick is skipped — the next iteration's reset + note handles it (the newest message might be "stop"; the model decides with that in view, not the loop). The `!interruptRequested` gate covers the other abort path: **esc / `/interrupt` never kicks** — `interruptSession` sets the flag and aborts *without* enqueuing, so the queue check alone would have let an esc'd turn resume itself; esc means "stop what you're doing". Whenever the loop is about to exit on an empty queue the flag is cleared so it never dangles. It is deliberately **not** in `runSession`'s `finally`: `tryReportToParent` / `deliverRelayReport` fire there and must see the *finished* work, so the kick turn has to complete first.
|
|
214
|
+
- **Interim report at turn end** (`sendInterimReport`, called in `drainQueue` right after `runAgentTurn` returns, before the kick block): the next turn resets `output` / `finalOutput`, and every end-of-run report (`tryReportToParent` / `deliverRelayReport`) reads only the last turn — so a turn that *another turn follows* would never reach whoever asked. Trigger: `(selfKick && !interruptRequested) || messageQueue.length > 0` (a kick is coming, or a message is already queued — the kick itself is skipped then, the interim must not be) **and** the turn was an answer (`selfKick` set, or `finalOutput !== ''` from a natural `end_turn`). An esc after `continue_task` fails the first half → no kick → the run ends → the final report carries the turn, no interim. The per-turn reset stays (get_session_output, the session file and the report cap depend on it); instead the turn's text is snapshotted here and forwarded **only when the batch came from the party this session owes a report** — the pure `interimDoor(parentId, batch)` picks the recipient (`'parent'` / `'relay'` / null): a sub-agent whose batch has a message with `sourceSessionId === parentId` → `querySession(parent, self, "[Interim report · status: still running] … Not the final result.\n\n<answer>")`, **without** stamping `stoppedAt` or emitting `agent_done` (the parent's sibling-status still lists the child as running → "Do NOT wrap up yet"; the real auto-report follows at the end); a root whose batch has a relay-prefixed message → `deliverRelayInterim` (keeps `reply_to`, see [relay.md → Interim report](relay.md#interim-report-continue_task)). Both paths share `deliverInterim(session, 'parent' | 'relay')`. The body is the **whole** turn's `output`, not `finalOutput`: the response carrying the `continue_task` tool_use is `stopReason: tool_use`, so its text is `final: false` and never lands in `finalOutput` — an answer written before the call would otherwise be dropped. Nothing for local user chat (the channel already streamed the answer and a `batchBoundary` complete flushes it), a sibling/other-session message, or an errored turn. Capped at `limits.autoReportMax`.
|
|
215
|
+
- **Opening-turn interim** (`sendOpeningInterim`, in `runSession`'s `runFn` right after the `hasFirst` turn): the same loss happens when a follow-up is queued while the opening turn runs — the drain's first turn resets the opening wrap-up. Fires when `messageQueue` is non-empty, `finalOutput !== ''` and no `turnError`. A sub-agent's opening message is its parent's dispatch, so it always owes the parent. A root owes its relay caller only when the opening message itself carries the relay prefix (same `interimDoor` as the drain path) **and** a `reply_to` row exists — a pending `reply_to` alone is not enough, or a local user chatting into a root that still owes a relay report would have their answer forwarded to the caller; otherwise nothing.
|
|
216
|
+
- **Deferred interim** (`sendDeferredInterim`, in `runSession`'s finally right before `tryReportToParent` / `deliverRelayReport`): a session waiting on its own sub-agents is asked by its caller (`relay_send` / the parent's `query_session`) and answers, and the run ends with a child still running — both end-of-run reports skip (subtree not quiet), and the child's later report wakes a new run whose first turn resets `output`, so the answer reached no one. The run's last turn (`RunLastTurn { batch, waitingOnChildren }`, refreshed before the opening turn and every drained batch) sends an interim through `sendInterimReport` when it answered the owed party (`interimDoor`) **while a child was already running at turn start** (`waitingOnChildren` — door first, db lookup only past it, so local chat / child reports / goal rounds / kicks never query; turn *start*, because the dispatch turn starts its child mid-turn and owes the final report, not an interim), `finalOutput !== ''`, no `turnError`, and the subtree is still busy at run end (`listActiveChildren`, the same db gate the two reports read synchronously right after: children running → they skip and this sends; none → they report the turn as final and this doesn't). `reply_to` is kept, so the final report still fires once. Never overlaps the drain path: a turn another turn follows is never the last.
|
|
217
|
+
- **Stop / delete / archive never resurrect**: `stopSession` and `stopUserSession` clear `selfKick` next to `interruptRequested`; `deleteSession` (root + children) and `archiveSession` clear it right after nulling `abortController` and *before* awaiting `session.promise` (the aborted turn's drain still runs inside that promise and would otherwise fire a real kick turn); and `requestSelfKick` returns `no_turn` when `session.abortController === null` — Stop nulls it *before* the abort lands, so a tool callback that completes after a Stop can't re-arm the flag. `enqueueUserMessage` (admin WS) mirrors `sendUserMessage` and does **not** set `interruptRequested` while a compact runs outside a live model loop (manual `/compact`, or the turn-end auto-compact — `abortController` already null) — there is no live turn, and the drain would otherwise snapshot it into `resumedAfterInterrupt` for a turn that interrupted nothing. A mid-turn auto-compact (`beforeCallModel`, `abortController` still set) does sit inside a live turn, so the flag is set and the turn yields after its next tool instead of running to its natural end with the message still queued.
|
|
212
218
|
|
|
213
|
-
**Stop preserves, archive discards.** Both `stopSession` and `stopUserSession` (the user Stop button) fold the **whole** `messageQueue` into `agent.messages` as a user turn (user entries raw, agent entries with their `(from: session X)` prefix) **before** aborting, then `repairConversationMessages` — a stop **parks** the work, it never drops a queued message (mirrors interrupt, which also never loses one). `archiveSession`, by contrast, clears the queue without folding — archiving a subtree is a deliberate discard.
|
|
219
|
+
**Stop preserves, archive discards.** Both `stopSession` and `stopUserSession` (the user Stop button) fold the **whole** `messageQueue` into `agent.messages` as a user turn (user entries raw, agent entries with their `(from: session X)` prefix) **before** aborting, then `repairConversationMessages` — a stop **parks** the work, it never drops a queued message (mirrors interrupt, which also never loses one). While a compact is in flight the fold is parked in `foldAfterCompact` and lands when the compact ends (see [Compaction paths](#compaction-paths)). `archiveSession`, by contrast, clears the queue without folding — archiving a subtree is a deliberate discard.
|
|
214
220
|
|
|
215
221
|
#### Process-group kill on abort
|
|
216
222
|
|
|
@@ -246,7 +252,7 @@ The gate lives at the **tool callback layer** (`agents/session-tools.ts`), not i
|
|
|
246
252
|
|
|
247
253
|
### Viewing a sub-session's live log (`getSessionView`)
|
|
248
254
|
|
|
249
|
-
Because every event reduces into the **root's** UIState (a sub-agent's stream / tool calls land in `rootState.subSessionLogs[subId]`, keyed by the sub's full id), `getSessionView(subId)` must read from there — not from `uiStore.ensureUIState(subId)`, which is keyed by the sub's own id and would only ever see a cold disk-seeded snapshot (and, since the root is self-driven, never re-read disk → a live viewer would freeze on the first frame). So `getSessionView` special-cases a sub-session whose root holds it in `subSessionLogs` (read via `uiStore.getCachedUIState(rootId)`): it snapshots that sub-log directly (in-flight buffers included, fresher than disk). For the root-view path it calls `uiStore.prepareForView(sessionId, selfDriven)`, which evicts a stale cache when the session isn't self-driven so the snapshot reflects disk. Once the sub finishes, `agent_done` deletes the sub-log
|
|
255
|
+
Because every event reduces into the **root's** UIState (a sub-agent's stream / tool calls land in `rootState.subSessionLogs[subId]`, keyed by the sub's full id), `getSessionView(subId)` must read from there — not from `uiStore.ensureUIState(subId)`, which is keyed by the sub's own id and would only ever see a cold disk-seeded snapshot (and, since the root is self-driven, never re-read disk → a live viewer would freeze on the first frame). So `getSessionView` special-cases a sub-session whose root holds it in `subSessionLogs` (read via `uiStore.getCachedUIState(rootId)`): it snapshots that sub-log directly (in-flight buffers included, fresher than disk). For the root-view path it calls `uiStore.prepareForView(sessionId, selfDriven)`, which evicts a stale cache when the session isn't self-driven so the snapshot reflects disk. Once the sub finishes, `agent_done` flushes its log whole to its own file and deletes the in-memory sub-log, so the call falls through to the on-disk file (the next `query_session` re-seeds the sub-log from that file). This is what lets the TUI `/log` viewer refresh a running sub-agent's log in real time.
|
|
250
256
|
|
|
251
257
|
### Idle UIState eviction
|
|
252
258
|
|
|
@@ -254,7 +260,7 @@ Because every event reduces into the **root's** UIState (a sub-agent's stream /
|
|
|
254
260
|
|
|
255
261
|
An idle-sweep candidate is refused eviction while `hasActiveWorkInTree(rootId)` is true — any session in the root's tree (root or `root>…` descendants) with an in-flight `promise` or `isCompacting` exempts the **whole tree**, because a fire-and-forget sub-agent's live stream/tool buffers hang off the *root's* UIState even after the root itself released between turns. This was chosen over the two eviction points that looked simpler but fail on verified facts: dropping in `releaseSession` (turn end) would force an admin-open or mid-conversation channel session to re-read its full file from disk on every next message — hot-path I/O; "drop when no active subscribers" would exempt exactly the leak's dominant source, since a channel account's `InboundBridge` listener subscribes permanently.
|
|
256
262
|
|
|
257
|
-
`dropUIState` flushes a pending debounced persist **before** evicting — otherwise an immediate rehydration would read the shorter on-disk file, and that rehydrated state's next persist would overwrite the orphaned timer's later, complete write. After eviction, the next access (`ensureUIState`) rehydrates from disk exactly as it already does for a cold session — same code path, no special-casing.
|
|
263
|
+
`dropUIState` flushes a pending debounced persist — the root's and its tree's pending sub-session writes — **before** evicting — otherwise an immediate rehydration would read the shorter on-disk file, and that rehydrated state's next persist would overwrite the orphaned timer's later, complete write. After eviction, the next access (`ensureUIState`) rehydrates from disk exactly as it already does for a cold session — same code path, no special-casing.
|
|
258
264
|
|
|
259
265
|
### SQLite metadata
|
|
260
266
|
|
|
@@ -272,7 +278,7 @@ Table `agent_sessions` holds metadata only (no runtime state):
|
|
|
272
278
|
| created_at / updated_at | INTEGER | Epoch ms |
|
|
273
279
|
| stopped_at | INTEGER | null = active |
|
|
274
280
|
| archived_at | INTEGER | null = not archived |
|
|
275
|
-
| goal / goal_session_id | TEXT |
|
|
281
|
+
| goal / goal_session_id | TEXT | Retained legacy columns (goal mode binding JSON / back-pointer); existing bindings are unchanged and still honored |
|
|
276
282
|
| reply_to | TEXT | Relay: JSON `{ workspace, sessionId }` of the caller in another workspace, set by `relay_send`, cleared when the report is delivered — see [relay.md](relay.md) |
|
|
277
283
|
| title / exchange_count / context_tokens / total_output_tokens | TEXT / INTEGER | List-visible metadata mirrored from the session file header; null = row predates the columns |
|
|
278
284
|
|
|
@@ -301,6 +307,8 @@ A `toolUseId`-based algorithm that repairs message arrays damaged by abort / int
|
|
|
301
307
|
|
|
302
308
|
**Partial batches land, only the cut tool is synthesized** (`agent-loop.ts`, the tool loop's `try/finally`): in a parallel `tool_use` batch the loop pushes the accumulated `tool_result` user message in a `finally`, so both interrupt exits — the loop's own cancel check (soft interrupt: the consumer aborts after a `tool_result`, the next tool sees the signal) and the consumer breaking its `for await` (hard interrupt: finishes the generator at the `yield` via `.return()`) — still land the results that finished. Before this, either exit skipped the push, so every completed result was lost and repair marked the **whole** batch `[interrupted]`; the model then re-ran work that had already happened, which for side-effecting calls (commit, append, send) is a double execution — and `continue_task`'s "re-issue interrupted calls" instruction made that hazard live. The tool the abort landed on is dropped on purpose (its result is a killed shell's partial output), so repair pairs exactly that id — and any never-started ones — with the do-not-retry marker.
|
|
303
309
|
|
|
310
|
+
**Partial streamed text lands** (`agent-loop.ts`, `run()`'s model-call `try/finally`): every streaming provider re-establishes the non-streaming contract on a cancel — it throws `AbortError` and returns no partial result — so the text already streamed existed only as `text_delta` events. The UI log persisted it (`appendStream`), the user read it, but `agent.messages` had no assistant turn: the next user message coalesced into the dangling user turn and, asked "what did you just write?", the model answered it had no record. The loop now accumulates `text_delta`s per model call and, when the call did **not** resolve and the **caller's** `cancelSignal` is aborted, pushes them as one assistant text block suffixed with `INTERRUPTED_REPLY_MARKER` (`[reply interrupted by the user here — the text above is what was shown before the cut]`). Same two exits as the tool batch above: the provider's rethrown `AbortError`, and the consumer breaking its `for await` at a delta `yield` (`runAgentTurn`'s `if (signal.aborted) break` → `.return()` → `finally`). Deliberately narrow: **text only** — a partial thinking block has no signature and Anthropic / Bedrock reject unsigned thinking in history, and a partial `tool_use` never reaches this point (the providers throw rather than hand back a half-built input that would be JSON-parsed / paired with a synthetic `tool_result` and make the model believe it made the call). **Gated on the caller's signal, not the idle-timeout controller**: a `MODEL_TIMEOUT_ERROR` is retried by `runAgentTurn` and the retry re-streams from scratch, so landing there would duplicate the text. No new event is emitted (the UI already has the partial) and `output` / `finalOutput` are untouched. Repair is a no-op on the landed block (Phase 1 only strips *empty* text blocks); the next `run()` sees a trailing assistant turn and pushes the user message fresh instead of coalescing. An abort before the first text delta still leaves the dangling user turn, exactly as before.
|
|
311
|
+
|
|
304
312
|
**Provider-side consumers**: the OpenAI-style agents (DeepSeek / Kimi / Doubao / Hunyuan / Zhipu / Mantle / generic OpenAI) convert a user turn's `tool_result` blocks into tool-role messages — any non-`tool_result` content coalesced into the same turn (e.g. the synthesized result landing alongside real user text, or a stop-fold) is emitted as a following user message rather than silently dropped.
|
|
305
313
|
|
|
306
314
|
### UI-side interrupt marker (`markPendingToolCallsInterrupted`)
|
|
@@ -312,7 +320,7 @@ The synthesized `[interrupted]` result above is **model-facing only** (`agent.me
|
|
|
312
320
|
3. **`stopSession`**'s cascade — per descendant, before awaiting the aborted promise (runSession's finally emits `complete`, which flushes and clears the pending buffers this scans).
|
|
313
321
|
4. **`stopUserSession`** (the user Stop button).
|
|
314
322
|
|
|
315
|
-
Idempotent — completed tools (output already set) are never overwritten. Two supporting details: `session-ui-store.flushSubSession()`
|
|
323
|
+
Idempotent — completed tools (output already set) are never overwritten. Two supporting details: `session-ui-store.flushSubSession()` writes a stopped sub-session's log to its own file immediately (the synthetic results only arm the 500ms debounce, and the interrupt is a turn boundary — the marker shouldn't ride a timer); `ui-log-builder.setToolResult` attaches an incoming result to the **first** entry without an output (not the last entry) and never overwrites a completed one — fixes reversed attachment under parallel tool calls and keeps the marker idempotent.
|
|
316
324
|
|
|
317
325
|
### SDK block format handling
|
|
318
326
|
|
|
@@ -343,7 +351,7 @@ Frontend network issues don't affect the backend:
|
|
|
343
351
|
2. **Grace period**: fixed `config.timeout.sessionGrace` (5 min default) — a single `setTimeout`, no auto-extension
|
|
344
352
|
3. **Event buffering**: the detached handler uses `bufferDetachedNotification` to buffer structural events in `pendingEvents[]`. All state (messageLog / tokens / etc.) lives in SessionManager's UIState, not duplicated in the handler.
|
|
345
353
|
4. **Reconnect**: a `subscribe` with the same sessionId loads UIState from SessionManager, replays `pendingEvents`, and — if still running — resumes live streaming
|
|
346
|
-
5. **Detach-save dirty gate**: every WS write-back path (disconnect cleanup, grace expiry, session switch/clear, backgroundSaves) funnels through `saveSession()`, which skips any UIState that isn't **dirty** — i.e. this process never reduced an event / appended a notification / replaced the log since the last successful persist (`SessionUIStore.isUIStateDirty`). A clean state is either a pure disk seed (built by `prepareForView` to *view* a session another process is driving — e.g. a cron `halo cli` child appending to the same file) or already flushed; writing it back would clobber the fresher file with a frozen snapshot. This was the cron-session UI-log truncation incident: admin viewed a cron session, closed the tab, and the grace-expiry save erased the cli child's final minutes of messages (`rawMessages`/`output` survived only via `saveSessionToFile`'s read-merge). The flag is cleared on successful `
|
|
354
|
+
5. **Detach-save dirty gate**: every WS write-back path (disconnect cleanup, grace expiry, session switch/clear, backgroundSaves) funnels through `saveSession()`, which skips any UIState that isn't **dirty** — i.e. this process never reduced an event / appended a notification / replaced the log since the last successful persist (`SessionUIStore.isUIStateDirty`). A clean state is either a pure disk seed (built by `prepareForView` to *view* a session another process is driving — e.g. a cron `halo cli` child appending to the same file) or already flushed; writing it back would clobber the fresher file with a frozen snapshot. This was the cron-session UI-log truncation incident: admin viewed a cron session, closed the tab, and the grace-expiry save erased the cli child's final minutes of messages (`rawMessages`/`output` survived only via `saveSessionToFile`'s read-merge). A sub-session event changes only that sub's own file, so it does not mark the root dirty; `flushSession` likewise rewrites the root's file only when it is dirty. The flag is cleared on successful root `persistLog` (kept on failure so a later save retries) and on `dropUIState`/purge/`prepareForView` re-seed so a stale flag never leaks onto a fresh disk copy.
|
|
347
355
|
|
|
348
356
|
## Resilient execution loop
|
|
349
357
|
|
|
@@ -402,9 +410,9 @@ Three entry points with different quality / safety trade-offs:
|
|
|
402
410
|
|
|
403
411
|
| Trigger | Path | Compaction used | Rationale |
|
|
404
412
|
|---|---|---|---|
|
|
405
|
-
| 80% soft threshold (mid-turn auto) | `maybeAutoCompact()` via agent-loop's `beforeCallModel` hook — runs before each model call within a turn | **Self-compact** (`selfCompactSession`) — the agent summarizes its own context, then a tail micro-compact pass clears bulk tool output | The agent already has full context cached (prompt cache hit). No extra model call, no input duplication, no risk of losing tool_result semantics. Firing mid-turn (not just at turn end) stops a single long turn that accumulates many large tool results from blowing the window. |
|
|
413
|
+
| 80% soft threshold (mid-turn auto) | `maybeAutoCompact()` via agent-loop's `beforeCallModel` hook — runs before each model call within a turn | **Self-compact** (`selfCompactSession`) — the agent summarizes its own context, then a tail micro-compact pass clears bulk tool output. **Not interruptible, no wall-clock cap; local fallback** if the summary throws, idle-times-out or comes back empty (see below) | The agent already has full context cached (prompt cache hit). No extra model call, no input duplication, no risk of losing tool_result semantics. Firing mid-turn (not just at turn end) stops a single long turn that accumulates many large tool results from blowing the window. |
|
|
406
414
|
| Overflow mid-loop (`too many input tokens`, `Input is too long`, …) | `runAgentTurn` retry catch → `localCompactMessages` → retry | **Local** — `[role]: <first N chars>` concat, no network call | The model just refused this payload; an LLM round-trip now could stall the recovery path. Local is deterministic and instant; the next end-of-turn can re-summarize via self-compact. |
|
|
407
|
-
| User `/session compact` (web, WeChat) | `commands/compact.ts` / `SessionManager.compactSession` | **Self-compact**,
|
|
415
|
+
| User `/session compact` (web, WeChat) | `commands/compact.ts` / `SessionManager.compactSession` | **Self-compact**, cancellable, **no local fallback** | User explicitly requested it; self-compact reuses the cached context so it's fast. A failure (model error / idle timeout) leaves the context unchanged (`Compaction failed — context unchanged`) and is rethrown to the caller; an empty summary emits `Compaction skipped — no summary produced`. |
|
|
408
416
|
|
|
409
417
|
Self-compact **deep-snapshots the keep-region before running the summarize turn** (`messages.slice(cut).map(structuredClone)`), then injects a summarization instruction into the agent's own stream, captures the response, and rebuilds messages as `[summary + snapshot]`. This reuses the provider's prompt cache (no separate model needed) and preserves full semantic context including tool results.
|
|
410
418
|
|
|
@@ -412,15 +420,19 @@ The pre-run snapshot is load-bearing, not an optimization: `agent.run()` coalesc
|
|
|
412
420
|
|
|
413
421
|
As a final byte-trimming step, self-compact runs **micro-compact** over that snapshot (`microCompactMessages(cleanRecent, 1)`). Each kept-recent message may still carry a large tool result (e.g. a 50 KB `file_read`), so after summarizing it would otherwise re-cross the threshold immediately. Micro keeps only the newest `tool_result`'s content and clears the rest in place, preserving `tool_use_id` pairing so the next API call stays valid — no extra LLM round-trip. Micro-compact is **not** a standalone compaction path; `selfCompactSession` is its only call site — self-compact is the entry point, micro is its tail cleanup.
|
|
414
422
|
|
|
415
|
-
All paths share the same split logic —
|
|
423
|
+
All paths share the same split logic — `compactCut(messages)`, exported from `agents/compact.ts` (single source of truth for SessionManager — `selfCompactSession` + the two feasibility gates below — and `localCompactMessages`): keep the last `keepMessages` turns, advance the cut forward past any orphan `tool_result`-first user message (otherwise the next API call gets `unexpected tool_use_id`). The tail loop only ever moves the cut *up*, so `cut === 0` is exactly the `messages.length <= keepMessages` "nothing to compact" case.
|
|
424
|
+
|
|
425
|
+
**Bounds and Stop.** Self-compact has no wall-clock timeout: a long but still-streaming summary is fine, and a hung call is bounded only by the model-call idle timeout (`timeout.model_request`, re-armed on any data the stream delivers; expiry throws `MODEL_TIMEOUT_ERROR`). An **auto-compact is not interruptible** — `maybeAutoCompact` passes no signal. Stop / Esc (`stopSession`, `interruptSession`, `stopUserSession`, WS `chat:stop` / `chat:interrupt`, `/api/web/stop`) abort only the turn's own controller, which the loop checks once `beforeCallModel` returns, so they take effect after the compact finishes (the stop request itself waits until then). Messages arriving meanwhile queue and run as the next turn once the compact ends; a Stop that folds the queue into history during the compact parks the text in `foldAfterCompact`, and `clearCompacting` lands it on the post-compact history (`foldIntoAgentMessages`). A **manual `/compact`** has no turn, so `cancelCompact` (WS `chat:stop` / `chat:interrupt`, channel `/stop` / `/interrupt` and `POST /api/web/stop` while compacting with no turn running, and `stopUserSession`) cancels it: history rolls back and `Compact cancelled` is emitted.
|
|
426
|
+
|
|
427
|
+
**Auto-compact local fallback.** When the LLM summary throws, idle-times-out, or returns nothing, `localCompactFallback` runs the no-LLM compact instead of leaving the session over the threshold: `localCompactMessages` → tail `microCompactMessages(tail, 1)` → re-estimate tokens. The notice is `Auto-compacted N older messages (local fallback — LLM summary failed: <reason>)` (reason truncated to 80 chars + `…`), emitted with `compactEnd: true` so the WS layer sends `compact:done` and the admin leaves its compacting state. The history is compacted locally rather than left over the threshold, so a failing summarize is not re-run on every later model call.
|
|
416
428
|
|
|
417
|
-
**Notification contract — a "Compacting context…" preflight is only announced when compaction will actually run, and always gets an outcome line.** Both self-compact entry points (`maybeAutoCompact`, manual `compactSession`) check `compactCut() === 0` *before* emitting the preflight and bail silently when there's nothing to compact. After the preflight, every path closes it out: success pairs it with `Auto-compacted N older messages` / `Context compacted: …`; an empty LLM summary (a thinking-heavy model can legally spend its entire response on non-text blocks) emits `Compaction skipped — no summary produced
|
|
429
|
+
**Notification contract — a "Compacting context…" preflight is only announced when compaction will actually run, and always gets an outcome line.** Both self-compact entry points (`maybeAutoCompact`, manual `compactSession`) check `compactCut() === 0` *before* emitting the preflight and bail silently when there's nothing to compact. After the preflight, every path closes it out: success pairs it with `Auto-compacted N older messages` / `Context compacted: …`; an empty LLM summary (a thinking-heavy model can legally spend its entire response on non-text blocks) emits `Compaction skipped — no summary produced` on the manual path; a thrown summarize call emits `Compaction failed — context unchanged` and the manual path rethrows after emitting; the auto path closes both with the `Auto-compacted … (local fallback …)` notice above; a cancelled manual compact emits `Compact cancelled`. Previously the preflight fired first and `selfCompactSession`'s silent `return null` paths left an orphan "Compacting context…" with no outcome — see `memory/2026-08-04-compact-preflight-orphan.md` for the production forensics.
|
|
418
430
|
|
|
419
431
|
**Post-compact token estimate.** Every compact ends with `estimateMessageTokens(messages) + estimateMessageTokens(systemPrompt)` written to `session.lastContextTokens` — the number the 80% gate compares against until the next real `usage` event arrives. It is a heuristic (`chars / 3.5` for mixed CJK/English), and it counts every block type: `text` by length, `tool_use` by `name + JSON(input)`, `tool_result` by its string or nested text blocks, and each image — top-level or nested in a `tool_result` — a flat **1500 tokens** (Anthropic's `w*h/750` rule caps near 1600 at max size; we don't decode dimensions). A text-only count under-read image- and tool-heavy histories and pushed the next auto-compact out until the model itself rejected the payload.
|
|
420
432
|
|
|
421
433
|
**Local compact output is API-legal on its own.** `localCompactMessages` returns `[summary, ...recent]` where `recent` starts past any `tool_result`-first user message, so every kept `tool_result` has its `tool_use` in the kept tail and `repairConversationMessages` is a no-op on the result (also on a second pass over its own output). Pinned by `local-compact-roundtrip.test.ts` — without it the overflow retry would only have "worked" via the later corrupted-conversation repair retry.
|
|
422
434
|
|
|
423
|
-
Config (see `config.compact`): `keepMessages` / `maxSummaryInput` / `maxMessageSlice`
|
|
435
|
+
Config (see `config.compact`): `keepMessages` / `maxSummaryInput` / `maxMessageSlice` — editable in Settings → General → compact.
|
|
424
436
|
|
|
425
437
|
**Self-compact also archives the UI log.** `selfCompactSession` calls `uiStore.archiveOldMessages(session.id)` for **root sessions only** (`!session.parentId`), right before the compaction notice is emitted so the notice lands in the kept exchange rather than inside the archived segment. Compaction is the one path that already means "history shrinks here", which is why archiving hangs off it rather than off a timer or its own sweep — the size threshold below decides whether the call does anything. See [UI-log archiving](#ui-log-archiving).
|
|
426
438
|
|
|
@@ -458,7 +470,7 @@ Accepted edge, documented so it isn't "fixed": `rawMessages` shares the active f
|
|
|
458
470
|
|
|
459
471
|
1. **Segment first, active file second.** `writeArchiveSegment(n)` writes a path nothing references yet; only then is the active file rewritten with the kept exchange. The reverse order could drop messages before their archive exists.
|
|
460
472
|
2. **`archiveCount` in the active file is the commit marker.** A segment with `N > archiveCount` was never committed and no reader may reference it (both the WS anchor and the HTTP route check this). So a crash between the two steps leaves an unreachable orphan plus an active file that still holds every message — nothing lost. The next compact re-derives the same `N` and overwrites the orphan, which is why there's no reconciliation pass.
|
|
461
|
-
3. **Read-back verify, else roll memory back.** `
|
|
473
|
+
3. **Read-back verify, else roll memory back.** `persistLog` swallows IO errors like every session write, so `archiveOldMessages` re-reads `archiveCount` afterwards; if it is `< n` the truncated in-memory `messageLog` is restored from the two halves. Without that, the *next* ordinary persist would write the short log under the old count — that, not the crash, is how messages would actually go missing.
|
|
462
474
|
|
|
463
475
|
Segments are immutable and append-only (`N` from 1, monotonically). Deletion is filesystem-driven: `deleteArchiveSegments()` globs `<seg>.arch.*.json.gz` in the directory rather than trusting `archiveCount` or any in-memory map, so crash-orphaned segments go too. Both session-file delete paths (`deleteSessionFile`, `findAndDeleteSessionFile`) call it.
|
|
464
476
|
|
|
@@ -103,7 +103,7 @@ Unlike Telegram (grammy polling) or WeChat (HTTP long-poll), Socket Mode elimina
|
|
|
103
103
|
7. Strip the `<@botUserId>` mention prefix from the text
|
|
104
104
|
8. (DMs only) Check if text is a slash command (starts with `/` or `!`); if so, dispatch via `channels/shared/commands.ts` and return early
|
|
105
105
|
9. Download and ingest any attached files (save images + files to workspace media store; images base64'd for vision)
|
|
106
|
-
10. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)): resolve or create the session for this {channel, thread},
|
|
106
|
+
10. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)): resolve or create the session for this {channel, thread}, send a busy hint if needed, refresh the reply route `{channelId, replyTs}`, attach the `SlackResponder` listener once (torn down on `stopAccount` via `bridge.closeAll()`), then queue the user message with SessionManager
|
|
107
107
|
|
|
108
108
|
### Outbound (SlackResponder)
|
|
109
109
|
|
|
@@ -203,7 +203,7 @@ Text, images (base64 attached to agent input for vision), files. All files are d
|
|
|
203
203
|
|
|
204
204
|
The agent runtime produces `MEDIA: <path>` markers. SlackResponder:
|
|
205
205
|
- Intercepts the markers and calls `uploadFile()` for each one
|
|
206
|
-
- Blocks sandbox violation (files must be under workspace or /tmp)
|
|
206
|
+
- Blocks sandbox violation for non-`full` accounts (files must be under workspace or /tmp — `isMediaPathAllowed(path, workspace, accessLevel)`; `full` accounts may send any readable path). A block throws inside the same try as the upload, so it surfaces as the `handler.upload_failed` text below
|
|
207
207
|
- Falls back to error text if upload fails
|
|
208
208
|
|
|
209
209
|
Outbound media uses Slack's v2 upload flow (getUploadURLExternal → signed PUT → completeUploadExternal).
|
|
@@ -50,7 +50,7 @@ Defines the persisted-data format for every Halo surface. Format changes must re
|
|
|
50
50
|
├── sessions/<agentId>/ # Session files (one per regular session)
|
|
51
51
|
├── memory/ # Project memory (dated entries)
|
|
52
52
|
├── canvas/self.html # Agent's visual face — platform-owned, force-copied on every open (design/express-self.md)
|
|
53
|
-
├── goal/<goalId>/ #
|
|
53
|
+
├── goal/<goalId>/ # Retained legacy goal-mode artifacts (GOAL_SPEC.md + decision-<n>.md); existing goals unchanged
|
|
54
54
|
├── logs/
|
|
55
55
|
├── evo/ # Self-evolution per-workspace artifacts
|
|
56
56
|
│ ├── runs/<id>/ # per-evaluation: source-snapshot.json, tool-flow.md,
|
|
@@ -92,7 +92,7 @@ Protocol (single-machine scope; NFS / cross-host explicitly out):
|
|
|
92
92
|
- **Claim** = `O_EXCL` create with own pid; first claimer owns the runtime.
|
|
93
93
|
- **EEXIST** → read the holder pid and probe with `kill(pid, 0)`. Holder alive (`EPERM` counts as alive — false-dead is the dangerous direction) → return false, caller skips reconcile. Holder is own pid → already owned.
|
|
94
94
|
- **Dead / unreadable holder** → stale takeover via write-tmp + atomic rename (keeps the path existent at all times, so a third comer always sees *some* holder).
|
|
95
|
-
- **
|
|
95
|
+
- **Release on graceful exit**: `gracefulShutdown` calls `releaseWorkspaceRuntime` per workspace, which unlinks the file only when it holds *our own* pid (a file another live process owns or took over is left alone). A crash / SIGKILL leaves the file and converges on the "pid dead → next boot takes over" path, so correctness never depends on the exit hook having run — the release only keeps a reboot-recycled pid from reading as a live holder.
|
|
96
96
|
|
|
97
97
|
Bias is deliberate: pid reuse can read a stale pid as "alive," which only costs one missed cleanup round; a false-dead could stop another process's live sessions. Full race analysis in the file's header comment.
|
|
98
98
|
|
|
@@ -209,6 +209,8 @@ type ContentBlockEntry =
|
|
|
209
209
|
|
|
210
210
|
`turnId` uniquely identifies each LLM API call. Content blocks and the corresponding usage message in the same turn share the same turnId.
|
|
211
211
|
|
|
212
|
+
The `usage` event rotates `turnId` on both root and sub-session turn state without flushing the assistant message: the agent loop yields `tool_call`s before `usage` and runs the tools after, so at usage time every tool call of that model call is still pending — a flush there would split thinking/text from the tool_calls into two assistant messages (the pre-fix sub-session behavior). One assistant message therefore spans all model calls of a turn, each call's blocks tagged with its own turnId; it reaches disk at `complete` / `agent_done`, the next `user` or `system` event, or the interrupt flush. Root and sub-session logs persist by one rule: `persistLog` overwrites the session's own file whole from its UI log, and every save carries the in-flight turn as a temporary assistant message (`createSaveSnapshot`). A sub-session log starts from its own file (`loadSubHistory`), so it always holds the full history; writes are debounced 500ms per session id (tool_call / tool_result / usage / context / user / system / error) and flushed at turn end (root `complete`, sub `agent_done`) and on interrupt.
|
|
213
|
+
|
|
212
214
|
### Render rules
|
|
213
215
|
|
|
214
216
|
| Mode | Visible | Hidden |
|
|
@@ -332,7 +334,6 @@ general: # built-in declarer (the server itself
|
|
|
332
334
|
keep_messages: 5
|
|
333
335
|
max_summary_input: 15000
|
|
334
336
|
max_message_slice: 800
|
|
335
|
-
summarize_timeout_sec: 300
|
|
336
337
|
sandbox:
|
|
337
338
|
hidden_dirs: "~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh"
|
|
338
339
|
hidden_files: "~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/evo.db,~/.halo/global/evo.db-wal,~/.halo/global/evo.db-shm,~/.halo/global/cron.db,~/.halo/global/cron.db-wal,~/.halo/global/cron.db-shm,~/.halo/global/runs.db,~/.halo/global/runs.db-wal,~/.halo/global/runs.db-shm"
|
|
@@ -382,7 +383,6 @@ A value of the form `<<ENV_NAME>>` is replaced at read time with `process.env.EN
|
|
|
382
383
|
| `general.compact.keep_messages` | 5 | Recent messages kept intact during compaction |
|
|
383
384
|
| `general.compact.max_summary_input` | 15000 | Local truncation fallback total char cap |
|
|
384
385
|
| `general.compact.max_message_slice` | 800 | Local truncation per-message char cap |
|
|
385
|
-
| `general.compact.summarize_timeout_sec` | 300 | LLM summary timeout |
|
|
386
386
|
| `general.limits.shell_output_bytes` | 5242880 | Max bytes captured from one `shell_exec` (stdout+stderr); excess truncated with a `[truncated]` marker |
|
|
387
387
|
| `general.limits.web_fetch_bytes` | 51200 | Max bytes downloaded by one `web_fetch` |
|
|
388
388
|
| `general.limits.grep_default_matches` | 50 | Default `grep` match cap when no explicit `max` is passed |
|
|
@@ -576,7 +576,7 @@ The databases hold session metadata indexes and workspace-scoped preferences (e.
|
|
|
576
576
|
| created_at / updated_at | INTEGER | Unix ms |
|
|
577
577
|
| stopped_at | INTEGER | Stopped timestamp (null = active) |
|
|
578
578
|
| archived_at | INTEGER | Archived timestamp (null = not archived) |
|
|
579
|
-
| goal / goal_session_id | TEXT |
|
|
579
|
+
| goal / goal_session_id | TEXT | Retained legacy columns (goal mode binding JSON / back-pointer); existing bindings are unchanged and still honored |
|
|
580
580
|
| reply_to | TEXT | Relay — JSON `{ workspace, sessionId }` of the caller session in another workspace; set by `relay_send`, cleared on report delivery. Added in `HALO_MIGRATIONS` slot (`db/index.ts`). |
|
|
581
581
|
| title | TEXT | Mirror of the session file's `title` |
|
|
582
582
|
| exchange_count | INTEGER | Mirror: main user turns over the session's lifetime (kept + archived) |
|
|
@@ -22,7 +22,7 @@ All four IM channels (telegram / wechat / slack / feishu) route inbound messages
|
|
|
22
22
|
Three pieces:
|
|
23
23
|
|
|
24
24
|
- **`InboundBridge<Route>`** — per-account bookkeeping for session listeners + their reply routes (`setRoute` / `getRoute` / `ensureListener` / `dropListener` / `closeAll`). The **reply route** — where to send, plus any per-message credential like wechat's context token — is a per-session value refreshed on **every** inbound message, and responders read it lazily at send time via `bridge.getRoute(sessionId)`. A listener registered once therefore can't lock onto a stale destination. Route entries are dropped together with their listener, so the map can't outgrow the listener set (the old per-channel context-token map never got cleaned up) — but **only once the responder has drained**: `close()` may return a promise (slack / feishu / wechat serialize their per-chunk sends and hand one back; telegram still fires each chunk and returns `void`), and the bridge defers the route deletion until it settles. Dropping the route synchronously stranded the tail of a split reply with nowhere to send (audit A-L3). `ensureListener` is idempotent per session; the account runner owns the bridge so `stopAccount` tears everything down with `closeAll()`, which walks the same per-listener teardown — deliberately no blanket `routes.clear()`, since that would delete the routes in-flight sends are about to read.
|
|
25
|
-
- **`deliverInbound`** — the tail itself: `rememberLastActiveChat` → access-level projection → resolve-or-create the channel session →
|
|
25
|
+
- **`deliverInbound`** — the tail itself: `rememberLastActiveChat` → access-level projection → resolve-or-create the channel session → busy/compacting hint (hint only; delivery continues regardless) → `setRoute` → `ensureListener` → `appendUserMessage(uiText)` + `sendUserMessage("[channel: X | user: Y | thread: Z]\n" + agentText)`. Returns the session id.
|
|
26
26
|
- **`dispatchChannelCommand`** — wraps the shared `dispatchCommand` and, when the result says `startedTurn`, wires route + listener exactly like the plain-message path, so no channel can forget it again. A call site that can't build a route logs instead of attaching a responder that could never send.
|
|
27
27
|
|
|
28
28
|
Deliberately **not** shared (real semantic differences, not drift): message parsing, media download / ingest, slash-command gating (slack `!cmd` alias + DM-only, feishu p2p-only, telegram's `bot.command` registration, wechat `/qr`), the send primitives (thread routing, passive reply window), and post-command effects (workspace restart, wechat's `switchTo` listener migration).
|
|
@@ -98,7 +98,7 @@ Routes: `packages/server/src/routes/telegram.ts`
|
|
|
98
98
|
1. Check user against `allowedUsers` whitelist
|
|
99
99
|
2. Resolve workspace (check path exists on disk)
|
|
100
100
|
3. If the text is a registered command, `runCommand` (→ `dispatchChannelCommand`) and stop
|
|
101
|
-
4. Otherwise `handleUserMessage` → `deliverInbound` ([shared skeleton](#shared-inbound-skeleton)): session resolve/create →
|
|
101
|
+
4. Otherwise `handleUserMessage` → `deliverInbound` ([shared skeleton](#shared-inbound-skeleton)): session resolve/create → busy hint → route `{chatId}` + `TelegramResponder` listener → deliver
|
|
102
102
|
|
|
103
103
|
Photos / documents / voice / video notes trigger the same flow with the saved-file note in the text.
|
|
104
104
|
|
|
@@ -138,6 +138,8 @@ Outbound: the `send-file` skill produces `MEDIA:<path>` markers. The responder s
|
|
|
138
138
|
- `.ogg/.oga` → `sendVoice`
|
|
139
139
|
- anything else → `sendDocument`
|
|
140
140
|
|
|
141
|
+
Path whitelist: a `full` account may send any readable path; other access levels are limited to the workspace or the OS temp dir (`isMediaPathAllowed(path, workspace, accessLevel)`). A blocked path — or any failed send — is reported to the chat as `t('handler.upload_failed')` (`⚠️ 文件上传失败:name — error`), same as slack / feishu / wecom; before this it was only a server log line.
|
|
142
|
+
|
|
141
143
|
## Configuration
|
|
142
144
|
|
|
143
145
|
### BotFather setup
|
|
@@ -13,7 +13,7 @@ Halo server (9527) ──┤── channels/telegram/ ├── Sess
|
|
|
13
13
|
ilinkai.weixin.qq.com
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
All channels are peers — each one is a subscriber + caller against SessionManager. Common slash commands (`/help`, `/evo`, and the object commands `/session`, `/agent`, `/skill`, `/workspace`) live in `channels/shared/commands.ts`; each channel handler is a thin adapter that builds a `CommandContext` and formats the result for its transport. The inbound tail (session resolve →
|
|
16
|
+
All channels are peers — each one is a subscriber + caller against SessionManager. Common slash commands (`/help`, `/evo`, and the object commands `/session`, `/agent`, `/skill`, `/workspace`) live in `channels/shared/commands.ts`; each channel handler is a thin adapter that builds a `CommandContext` and formats the result for its transport. The inbound tail (session resolve → busy hint → responder wiring → deliver) is likewise shared — see [Shared inbound skeleton](telegram.md#shared-inbound-skeleton).
|
|
17
17
|
|
|
18
18
|
## Data model
|
|
19
19
|
|
|
@@ -46,11 +46,12 @@ Sub-sessions inherit the parent's access level — a readonly channel can't dele
|
|
|
46
46
|
|
|
47
47
|
WeChat bot accounts are stored in the unified channel DB: `~/.halo/secrets/channels/channels.db`, table `channel_accounts` with `channel_type = 'wechat'`. See [storage.md](storage.md#channel_accounts) for the full schema.
|
|
48
48
|
|
|
49
|
-
WeChat-specific config JSON fields: `botToken`, `baseUrl`, `userId`, `syncBuf`, `lastActiveChatId`, `contextTokens`.
|
|
49
|
+
WeChat-specific config JSON fields: `botToken`, `baseUrl`, `userId`, `syncBuf`, `lastActiveChatId`, `contextTokens`, `contextTokenAts`.
|
|
50
50
|
|
|
51
51
|
- `userId` is the QR-bind owner (the ilink_user_id of whoever scanned the QR code to register this bot). Used by the wechat cron-dispatcher as the default proactive-send target — sending here means cron output goes back to the bot's owner, which is the common-case "report to me on a schedule" intent.
|
|
52
52
|
- `lastActiveChatId` is a runtime cache of the most recent inbound `from_user_id`. Written by `rememberLastActiveChat()` in `channels/shared/accounts.ts` with a per-process hash so unchanged values never touch the db (idempotent — hot path stays in memory). Used as a fallback for cron sends in the shared-bot case where you want to reply to whoever talked last.
|
|
53
53
|
- `contextTokens` is a `Record<userId, context_token>` — the most recent inbound `context_token` per user, written by `rememberWechatContextToken()` (same in-process dedupe / write-on-change shape as `lastActiveChatId`). The ilink gateway expects every outbound `sendmessage` to echo the recipient's latest inbound token; the chat-reply path already carries it in the in-memory bridge route, but cron dispatch runs long after the inbound and needs a persisted copy.
|
|
54
|
+
- `contextTokenAts` is the parallel `Record<userId, epochMs>` of when each token arrived, written in the same `patchConfig` call. The token also **expires**: the gateway rejects a stale one with the same `ret=-2`, and only a new inbound message from that user refreshes it (window not fixed — observed rejected at 12 min and 79 min after the last inbound, accepted at 68 min). The stamp travels with the token (`WxRoute.contextTokenAt` in memory, replayed by `restoreReplyRoutes` after a restart, read by the cron dispatcher) purely so a `ret=-2` can say `context_token age 79m` vs `context_token missing`; accounts whose token predates the stamp report `age unknown`.
|
|
54
55
|
|
|
55
56
|
### Proactive sending (cron)
|
|
56
57
|
|
|
@@ -62,7 +63,7 @@ The wechat cron-dispatcher (`channels/wechat/cron-dispatcher.ts`) registers itse
|
|
|
62
63
|
|
|
63
64
|
WeChat is single-recipient per dispatch (no fan-out across `allowedUsers` like telegram has). If none of the three yields an id, dispatch fails with a clear "no wechat target — bind the account first" message.
|
|
64
65
|
|
|
65
|
-
The text is chunked exactly like a chat reply — `splitText(text, WECHAT_TEXT_LIMIT)` (3500 chars, see [Event coalescing](#event-coalescing-wechatresponder)) — and the chunks are sent sequentially so a long report arrives in order. Before this, a whole report went out in one `sendmessage` call and anything over the gateway's 16 KB ceiling failed outright with `ret=-2 "prepare failed"` (the stock-report job hit it routinely). A mid-chunk failure rethrows as `chunk i/n: <error>`, so the run row's dispatch result shows how much of the report already landed (chunks before `i` were delivered). Every chunk carries the recipient's persisted `context_token` (`config.contextTokens[chatId]`, see above) — the ilink gateway expects outbound to echo the latest inbound token, and sending without it was the second `ret=-2` cause: 6 failures in 10 days on payloads far under 16 KB, one of them 6 minutes after the user had just been chatting with the bot, which rules out the earlier "no recent inbound message" theory. Accounts with no inbound since 1.1.8 have no token yet and still send bare (as before) until the owner's next message. `api.ts`'s `ret=-2` hint names
|
|
66
|
+
The text is chunked exactly like a chat reply — `splitText(text, WECHAT_TEXT_LIMIT)` (3500 chars, see [Event coalescing](#event-coalescing-wechatresponder)) — and the chunks are sent sequentially so a long report arrives in order. Before this, a whole report went out in one `sendmessage` call and anything over the gateway's 16 KB ceiling failed outright with `ret=-2 "prepare failed"` (the stock-report job hit it routinely). A mid-chunk failure rethrows as `chunk i/n: <error>`, so the run row's dispatch result shows how much of the report already landed (chunks before `i` were delivered). Every chunk carries the recipient's persisted `context_token` (`config.contextTokens[chatId]`, see above) — the ilink gateway expects outbound to echo the latest inbound token, and sending without it was the second `ret=-2` cause: 6 failures in 10 days on payloads far under 16 KB, one of them 6 minutes after the user had just been chatting with the bot, which rules out the earlier "no recent inbound message" theory. Accounts with no inbound since 1.1.8 have no token yet and still send bare (as before) until the owner's next message. The third `ret=-2` cause is an **expired token** (see `contextTokenAts` above) — a push that echoes a token from an old inbound is rejected once the gateway's validity window has passed, and nothing halo does refreshes it; the recipient has to message the bot again. `api.ts`'s `ret=-2` hint names all three causes (oversized payload / no token / expired token) and appends the echoed token's age (`context_token age 79m` · `context_token missing` · `context_token age unknown`), also written as a `[WeChat:sendmessage] ret=-2 …` warn line.
|
|
66
67
|
|
|
67
68
|
## Modules
|
|
68
69
|
|
|
@@ -101,7 +102,7 @@ loop {
|
|
|
101
102
|
1. Extract `from_user_id` / text + media items / `context_token`
|
|
102
103
|
2. Process inbound media: images → base64 attached to the agent message; voice / video / files → saved under the workspace with a `[voice saved: ...]` etc. marker appended to the text
|
|
103
104
|
3. If the text begins with `/`, dispatch a slash command (see below) — if it returns `handled`, stop here
|
|
104
|
-
4. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)), which caches `lastActiveChatId`, resolves-or-creates the session,
|
|
105
|
+
4. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)), which caches `lastActiveChatId`, resolves-or-creates the session, sends a busy/compacting hint when needed, refreshes the reply route (`wxRoute(fromUserId, context_token)`), attaches the `WechatResponder` listener once, and delivers: clean text to the UI log, `[channel: wechat | user: <id>]\n<text>` to the agent (so it knows how to address the media-send skill)
|
|
105
106
|
|
|
106
107
|
## Slash commands
|
|
107
108
|
|
|
@@ -127,7 +128,8 @@ WeChat `sendMessage` is block-send, while LLMs stream. The current strategy is "
|
|
|
127
128
|
- Every chunk goes through one serialized send chain per responder (`sendTail`, same as Slack / Feishu) so a long reply arrives in order; `close()` returns the drain promise and `InboundBridge` keeps the reply route alive until it settles
|
|
128
129
|
- `error` → immediate flush + send a `[错误] …` message; `system` → immediate flush + send a `[系统] …` message
|
|
129
130
|
- Tool calls / tool results / thinking are dropped (detail lives in the web UI)
|
|
130
|
-
- Media: the agent emits `MEDIA: <path>` markers, which the responder extracts and turns into actual media uploads.
|
|
131
|
+
- Media: the agent emits `MEDIA: <path>` markers, which the responder extracts and turns into actual media uploads. A `full` account may send any readable path (its shell / file tools are unrestricted anyway); other access levels are limited to the account's workspace or the OS temp dir (`/tmp`) — `isMediaPathAllowed(path, workspace, accessLevel)`. A blocked path (e.g. another workspace's files) throws inside `sendMedia`, so it rides the responder's `onSendError` path and lands in the session log as a `⚠️ WeChat delivery failed: sendMedia … media path not allowed` notification instead of being dropped silently
|
|
132
|
+
- Send failures are surfaced, never retried: when `sendText` / `sendMedia` rejects (typically the gateway's `ret=-2`), `dispatchChunk` logs it and calls the optional `onSendError(message)` dep; the handler wires that to `sm.appendNotification(sessionId, '⚠️ WeChat delivery failed: …')`, so the session log shows the turn ran but its reply never reached WeChat. `appendNotification` only appends a `notification` row and persists — it emits no event, which matters: a `system` event would loop back into this responder's `case 'system'` and try to send to WeChat again
|
|
131
133
|
|
|
132
134
|
**The responder never captures its recipient.** Both `sendText` and `sendMedia` read `bridge.getRoute(sessionId)` at send time, so a `{fromUserId, contextToken}` refreshed by a later inbound message takes effect immediately. Closing over `fromUserId` at listener-registration time was the A-M2 bug: after a full-access `/session switch` moved a session to another user, the listener kept replying to the *first* one. `contextToken` (WeChat's passive-reply-window credential) is carried over between messages **only while the user is the same** — a new user's route starts without the old token rather than replaying it against the new recipient. `/session switch` additionally calls `bridge.dropListener(oldSid)` before wiring the target so the abandoned session doesn't keep a live responder.
|
|
133
135
|
|
|
@@ -133,7 +133,7 @@ All frames are JSON `{ cmd, headers: { req_id }, body }`; responses echo `header
|
|
|
133
133
|
5. Group text: strip leading `@token` mentions (`/^(@\S+\s*)+/`). WeCom only delivers group messages that `@` the bot, so there is no `shouldRespond` check. A multi-word bot name leaves its tail as residue — preferable to guessing the name and eating the user's first word
|
|
134
134
|
6. Slash command dispatch — **single chat only** (a group's shared session belongs to everyone). `/workspace switch` persists the new path on the account row; the reply goes out via `replyStream`
|
|
135
135
|
7. Skip if neither text/notes nor images remain
|
|
136
|
-
8. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)): create or retrieve the session (with inherited access level)
|
|
136
|
+
8. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)): create or retrieve the session (with inherited access level); busy / compacting hint via `replyStream`; refresh the route and attach the `WecomResponder` listener once; `sm.sendUserMessage(sessionId, agentInput, images?)` with `[channel: wecom | user: <userid>]` (+ ` | thread: <chatid>` in groups)
|
|
137
137
|
|
|
138
138
|
## Event coalescing (WecomResponder)
|
|
139
139
|
|
|
@@ -153,7 +153,7 @@ Chunk sends are serialized per responder (`sendTail` promise chain, audit A-L3)
|
|
|
153
153
|
|
|
154
154
|
**Inbound:** text, voice (as transcript), images (vision + saved), mixed (text + images), files, videos — single chat only for everything but text. Saved under `<workspace>/.halo/assets/wecom/inbound/<accountId>/<date>/`.
|
|
155
155
|
|
|
156
|
-
**Outbound routing** (`sendWecomMedia
|
|
156
|
+
**Outbound routing** (`sendWecomMedia`; for non-`full` accounts the path must be under the workspace or the OS temp dir — `isMediaPathAllowed(path, workspace, accessLevel)`, `full` accounts may send any readable path; a blocked path surfaces as the same `t('handler.upload_failed')` reply as a failed upload):
|
|
157
157
|
- `.png` / `.jpg` / `.jpeg` / `.gif` → `uploadMedia(type: 'image')` → `replyMedia('image')`
|
|
158
158
|
- `.mp4` → `uploadMedia(type: 'video')` → `replyMedia('video')`
|
|
159
159
|
- everything else (`.webp` / `.bmp` / `.mov` / documents / audio) → `uploadMedia(type: 'file')` → `replyMedia('file')` — WeCom's `image` accepts png/jpg/gif only and `video` mp4 only
|