@turmind/halo 1.5.4 → 1.5.5
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/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/{133-33aa091fba853037.js → 422-cc74b1fdd3af5415.js} +1 -1
- package/admin-out/_next/static/chunks/989-bc743317498d3fbc.js +1 -0
- package/admin-out/_next/static/chunks/app/page-0a390059f1914664.js +1 -0
- package/admin-out/_next/static/chunks/{webpack-1a264efd965db7ab.js → webpack-edf248e07333566d.js} +1 -1
- package/admin-out/_next/static/css/b53542687ebd4c65.css +3 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +5 -5
- package/bundled-docs/design/agent.md +4 -4
- package/bundled-docs/design/agentcore.md +13 -10
- package/bundled-docs/design/architecture.md +24 -14
- package/bundled-docs/design/command.md +5 -5
- package/bundled-docs/design/cron.md +2 -2
- package/bundled-docs/design/evolution.md +34 -35
- package/bundled-docs/design/express-self.md +6 -4
- package/bundled-docs/design/feishu.md +18 -14
- package/bundled-docs/design/halo-city.md +5 -4
- package/bundled-docs/design/observability.md +9 -9
- package/bundled-docs/design/prompt-system.md +12 -12
- package/bundled-docs/design/session.md +12 -12
- package/bundled-docs/design/slack.md +22 -20
- package/bundled-docs/design/storage.md +18 -19
- package/bundled-docs/design/telegram.md +26 -8
- package/bundled-docs/design/web.md +3 -1
- package/bundled-docs/design/wechat.md +8 -6
- package/bundled-docs/design/wecom.md +23 -9
- package/bundled-docs/design/ws.md +6 -6
- package/bundled-docs/dev/acp-adapter.md +8 -8
- package/bundled-docs/dev/add-channel.md +12 -9
- package/bundled-docs/dev/add-model-provider.md +31 -20
- package/bundled-docs/dev/api.md +83 -57
- package/bundled-docs/dev/deploy.md +8 -6
- package/bundled-docs/dev/env.md +16 -11
- package/bundled-docs/dev/logging.md +15 -29
- package/bundled-docs/dev/previews.md +20 -11
- package/bundled-docs/dev/tools.md +24 -12
- package/bundled-docs/guide/README.md +1 -0
- package/bundled-docs/guide/agents.md +19 -53
- package/bundled-docs/guide/channels/README.md +7 -4
- package/bundled-docs/guide/channels/acp.md +3 -1
- package/bundled-docs/guide/channels/feishu.md +13 -11
- package/bundled-docs/guide/channels/slack.md +12 -10
- package/bundled-docs/guide/channels/telegram.md +21 -27
- package/bundled-docs/guide/channels/web.md +15 -11
- package/bundled-docs/guide/channels/wechat.md +15 -14
- package/bundled-docs/guide/channels/wecom.md +7 -2
- package/bundled-docs/guide/chat.md +10 -9
- package/bundled-docs/guide/cli.md +10 -8
- package/bundled-docs/guide/getting-started.md +3 -3
- package/bundled-docs/guide/secrets-and-credentials.md +3 -3
- package/bundled-docs/guide/sessions.md +3 -3
- package/bundled-docs/guide/skills.md +8 -6
- package/bundled-docs/guide/testing-agents-and-skills.md +14 -15
- package/bundled-docs/guide/workspace.md +27 -15
- package/bundled-docs/requirements/agent.md +19 -18
- package/bundled-docs/requirements/chat.md +16 -17
- package/bundled-docs/requirements/command.md +10 -10
- package/bundled-docs/requirements/editor.md +10 -9
- package/bundled-docs/requirements/explorer.md +10 -7
- package/bundled-docs/requirements/session.md +16 -17
- package/bundled-docs/requirements/settings.md +18 -14
- package/bundled-docs/requirements/skill.md +4 -4
- package/bundled-docs/requirements/source-control.md +4 -3
- package/bundled-docs/requirements/terminal.md +13 -13
- package/bundled-docs/requirements/workspace.md +19 -13
- package/dist/index.js +2038 -3558
- package/package.json +1 -1
- package/templates/config.yaml +6 -0
- package/templates/skills/workspace/share.md +11 -3
- package/templates/skills/workspace/stage.py +85 -5
- package/admin-out/_next/static/chunks/989-92e9683ae8d39750.js +0 -1
- package/admin-out/_next/static/chunks/app/page-7a635969198fc62b.js +0 -1
- package/admin-out/_next/static/css/4ea5f674146e54d8.css +0 -3
- /package/admin-out/_next/static/{lm8Bz3ugTSORyuSXfyx56 → cuVcldfdg1PXevaQMQSzb}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{lm8Bz3ugTSORyuSXfyx56 → cuVcldfdg1PXevaQMQSzb}/_ssgManifest.js +0 -0
|
@@ -34,14 +34,14 @@ No `OTEL_EXPORTER_OTLP_ENDPOINT` after the mapping → `enabled=false`, `initObs
|
|
|
34
34
|
|
|
35
35
|
| module | role |
|
|
36
36
|
|---|---|
|
|
37
|
-
| `packages/server/src/observability/otel.ts`
|
|
38
|
-
| `packages/server/src/observability/otel-sdk.ts`
|
|
39
|
-
| `packages/server/src/observability/genai-spans.ts`
|
|
37
|
+
| `packages/server/src/observability/otel.ts` | Bootstrap. `initObservability()` / `shutdownObservability()`, the `enabled` gate, `tracer` / `getMeter()` / `otelLogger` / `captureContent()` exports. |
|
|
38
|
+
| `packages/server/src/observability/otel-sdk.ts` | The ONE module that imports the SDK packages and OTLP exporters. |
|
|
39
|
+
| `packages/server/src/observability/genai-spans.ts` | The span model: `beginTurn` / `onAgentEvent` / `recordRetry` / `endTurn`. |
|
|
40
40
|
| `packages/server/src/agents/session-manager.ts` | Calls the four hooks above from the turn loop (see below). |
|
|
41
41
|
| `packages/server/src/logger.ts` | Bridges every `logger.*` call into an OTel LogRecord when `enabled`. |
|
|
42
|
-
| `packages/server/src/index.ts` | `await initObservability()` (
|
|
42
|
+
| `packages/server/src/index.ts` | `await initObservability()` (before `initLogger()`) and `await shutdownObservability()` (in `gracefulShutdown`). |
|
|
43
43
|
|
|
44
|
-
**Hook call sites in `session-manager.ts
|
|
44
|
+
**Hook call sites in `session-manager.ts`** (all in `runAgentTurn`): `beginTurn(session, message)` at the start, `onAgentEvent(session, event)` inside the agent event loop (one call per `AgentEvent`), `recordRetry(kind)` at the 7 retry catch sites, `endTurn(session, {error?})` once after the retry loop (the turn's single exit point, success or failure).
|
|
45
45
|
|
|
46
46
|
**The `enabled` gate.** `otel.ts` exports a single `enabled: boolean`, set once by `initObservability()`. Every hook in `genai-spans.ts` and the `logger.ts` bridge checks it first — an unconfigured server pays nothing beyond one boolean read per hook call.
|
|
47
47
|
|
|
@@ -49,9 +49,9 @@ No `OTEL_EXPORTER_OTLP_ENDPOINT` after the mapping → `enabled=false`, `initObs
|
|
|
49
49
|
|
|
50
50
|
**Why the meter is lazy.** The `@opentelemetry/api` tracer and logger are proxies — they stay no-op until a real provider is registered, then transparently start forwarding, so `tracer` / `otelLogger` can be module-level consts. The metrics API has **no proxy**: a `Meter` fetched via `metrics.getMeter()` before `setGlobalMeterProvider()` runs is a permanent no-op forever after, even once a provider registers later. `getMeter()` in `otel.ts` is therefore a function, not a const, and `genai-spans.ts` calls it lazily on first metric emission (`instruments()`, cached after).
|
|
51
51
|
|
|
52
|
-
**Dependencies** (`packages/server/package.json`): `@opentelemetry/api` 1.9.1, `api-logs` 0.222.0, `sdk-trace-node` / `sdk-trace-base` / `sdk-metrics` / `
|
|
52
|
+
**Dependencies** (`packages/server/package.json`): `@opentelemetry/api` 1.9.1, `api-logs` 0.222.0, `sdk-trace-node` / `sdk-trace-base` / `sdk-metrics` / `resources` 2.11.0, `sdk-logs` / `exporter-trace-otlp-proto` / `exporter-metrics-otlp-proto` / `exporter-logs-otlp-proto` 0.222.0, `semantic-conventions` 1.43.0. No gRPC exporter (see [Rejected alternatives](#rejected-alternatives)).
|
|
53
53
|
|
|
54
|
-
**Test coverage**: `packages/server/test/observability-genai-spans.test.ts` (
|
|
54
|
+
**Test coverage**: `packages/server/test/observability-genai-spans.test.ts` (8 cases) drives `beginTurn` / `onAgentEvent` / `endTurn` against an in-memory tracer + `InMemorySpanExporter`, asserting the three-level span tree, parent ids, semconv attribute names, and the `capture_content` on/off split.
|
|
55
55
|
|
|
56
56
|
## Span model
|
|
57
57
|
|
|
@@ -87,7 +87,7 @@ Child of `invoke_agent`, one per model call — created retroactively on the `us
|
|
|
87
87
|
| `gen_ai.input.messages` | | semconv JSON (see below) — **delta only**: messages appended since this turn's previous `chat` span (first call → the user message; later calls → that cycle's `tool_result`s) |
|
|
88
88
|
| `gen_ai.output.messages` | | semconv JSON, only the trailing assistant message |
|
|
89
89
|
|
|
90
|
-
Semconv message JSON: `[{role, parts:[{type:"text",content}|{type:"tool_call",id,name,arguments}|{type:"tool_call_response",id,
|
|
90
|
+
Semconv message JSON: `[{role, parts:[{type:"text",content}|{type:"tool_call",id,name,arguments}|{type:"tool_call_response",id,response}]}]`.
|
|
91
91
|
|
|
92
92
|
`gen_ai.input.messages` is incremental on purpose (since 1.3.1): replaying the full history on every model call made a turn's exported bytes O(n²) and the system prompt alone was ~60% of the volume — a 4-turn / 9-call session dropped from 129 KB to 44 KB of content attributes. Nothing is lost: the full conversation for a trace is the concatenation of its `chat` spans' input + output deltas in order (a `TurnState.messageCursor` tracks the boundary; a mid-turn compact that shrinks the history yields an empty delta and re-syncs the cursor). Verified that AgentCore Evaluations scores are unchanged — its evaluators read `invoke_agent`'s `task.*` and `execute_tool`'s `arguments/result`, not the chat messages.
|
|
93
93
|
|
|
@@ -105,7 +105,7 @@ Child of the `chat` span that requested it, one per tool call — created on the
|
|
|
105
105
|
| `gen_ai.tool.call.arguments` | | JSON |
|
|
106
106
|
| `gen_ai.tool.call.result` | | text, empty → `[no output]` |
|
|
107
107
|
|
|
108
|
-
Tool calls still pending when `endTurn` fires (turn interrupted/aborted mid-cycle) are closed as orphans
|
|
108
|
+
Tool calls still pending when `endTurn` fires (turn interrupted/aborted mid-cycle) are closed as orphans (no status set) with `halo.tool.orphaned=true`, so no trace is left with a dangling tool-call id.
|
|
109
109
|
|
|
110
110
|
All content attributes are capped (32 KB general cap, 2 KB per message part, 8 KB for system instructions); over-cap content is truncated with a `…[truncated N chars]` marker, and an over-cap message list drops the oldest messages first with an omission marker.
|
|
111
111
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
On agent startup, Halo assembles the system prompt by concatenating various MD files in a fixed order.
|
|
4
4
|
|
|
5
|
-
Entry point: `session-
|
|
5
|
+
Entry point: `session-agent-builder.ts` `SessionAgentBuilder.buildAgentInstance(agentId, sessionId, parentId?, workingDir?, accessLevel?)`
|
|
6
6
|
|
|
7
7
|
**Root agent rule**: `!parentId` (parentId null/undefined = root). Does not depend on any `is_default` field.
|
|
8
8
|
|
|
@@ -17,7 +17,7 @@ Entry point: `session-manager.ts` `buildAgentInstance(agentId, sessionId, parent
|
|
|
17
17
|
├── prompts/ ← user-editable system prompts (externalised)
|
|
18
18
|
│ ├── bootstrap/BOOTSTRAP.md ← first-run guidance
|
|
19
19
|
│ ├── all/ ← every-agent rules (TOOL_GUIDELINES.md, TOOL_SHELL[.windows].md, WORKSPACE_CONVENTIONS.md, RUNTIME.md)
|
|
20
|
-
│ └── root/ ← root-agent-only (
|
|
20
|
+
│ └── root/ ← root-agent-only (DELEGATION.md, WORKSPACE_MEMORY.md)
|
|
21
21
|
├── agents/<id>/{agent.yaml, AGENT.md}
|
|
22
22
|
└── skills/<id>/SKILL.md ← built-in `halo` skill carries platform self-knowledge (loaded on demand via activate_skill)
|
|
23
23
|
```
|
|
@@ -52,7 +52,7 @@ Resolution is by the agent's **folder** (`agentSourceDir`), not per file:
|
|
|
52
52
|
agent entirely (both `agent.yaml` and `AGENT.md`), with no per-file fallback
|
|
53
53
|
to global — a missing file inside it is just absent. YAML fields: `name` /
|
|
54
54
|
`description` / `model` / `tools` / `skills` / `system_prompt` / `context` /
|
|
55
|
-
`priority`.
|
|
55
|
+
`priority` / `internal` / `team`.
|
|
56
56
|
|
|
57
57
|
## Step 2 — Resolve MD file paths
|
|
58
58
|
|
|
@@ -127,14 +127,14 @@ Startup seeds `templates/prompts/{bootstrap,all,root}/` into `~/.halo/global/pro
|
|
|
127
127
|
|
|
128
128
|
Replacement is **whole-file**, not per-section — so split the platform-divergent content into its own file and keep the common content in an un-suffixed file. Example in `prompts/all/`: `TOOL_GUIDELINES.md` (common) + `TOOL_SHELL.md` (unix Shell section) / `TOOL_SHELL.windows.md` (cmd.exe, `dir`/`findstr`, `%USERPROFILE%`, `python` vs `python3`, `.py` skill caveats). To add a new platform difference anywhere, extract that block to `FOO.md` and add `FOO.windows.md`.
|
|
129
129
|
|
|
130
|
-
Result:
|
|
131
|
-
- `prompts/bootstrap/*.md` → `
|
|
132
|
-
- `prompts/all/*.md` → `
|
|
133
|
-
- `prompts/root/*.md` → `
|
|
130
|
+
Result (the returned `systemPrompts` object; the sections below write these fields as `bootstrapPrompt` / `allPrompt` / `rootPrompt`):
|
|
131
|
+
- `prompts/bootstrap/*.md` → `systemPrompts.bootstrap`
|
|
132
|
+
- `prompts/all/*.md` → `systemPrompts.all`
|
|
133
|
+
- `prompts/root/*.md` → `systemPrompts.root`
|
|
134
134
|
|
|
135
135
|
Missing directory or read failure: warn + use built-in fallback.
|
|
136
136
|
|
|
137
|
-
`loadSystemPrompts` also returns `files: { bootstrap: string[]; all: string[]; root: string[] }` — the absolute paths of the `.md` files actually read for each scope (empty when the built-in fallback was used). `session-
|
|
137
|
+
`loadSystemPrompts` also returns `files: { bootstrap: string[]; all: string[]; root: string[] }` — the absolute paths of the `.md` files actually read for each scope (empty when the built-in fallback was used). `session-agent-builder.ts` forwards this list into `AgentMeta.mdFiles`, which `/session context` renders one line per file (label `prompt/<scope>/<basename>`). When a scope falls back to the built-in constants, a single line `prompt/<scope> (built-in fallback): <dir>` is shown instead.
|
|
138
138
|
|
|
139
139
|
## Step 5 — Compose the MD prompt
|
|
140
140
|
|
|
@@ -159,8 +159,8 @@ Global and workspace-root INSTRUCTIONS share the same `## User Instructions` hea
|
|
|
159
159
|
mdPrompt ← incl. roster + working_dir scope (both inside composeMdPrompt)
|
|
160
160
|
+ "\n\nThe project workspace is at: {workspaceRoot}\n"
|
|
161
161
|
+ [optional] "Working directory: {workingDir}\n"
|
|
162
|
-
+ allPrompt
|
|
163
162
|
+ rootPrompt
|
|
163
|
+
+ allPrompt
|
|
164
164
|
```
|
|
165
165
|
|
|
166
166
|
(The `working_dir` directory-chain INSTRUCTIONS.md live *inside* `mdPrompt` — folded into the `## User Instructions` region by `composeMdPrompt` — not after the `Working directory:` tagline. That tagline is just a one-line focus marker.)
|
|
@@ -216,7 +216,7 @@ If `mdPrompt` is empty:
|
|
|
216
216
|
|
|
217
217
|
## Step 7 — Append skills and tool list
|
|
218
218
|
|
|
219
|
-
[session-
|
|
219
|
+
[session-agent-builder.ts](../../../packages/server/src/agents/session-agent-builder.ts) `composeSystemPrompt`
|
|
220
220
|
|
|
221
221
|
### Skills (progressive disclosure)
|
|
222
222
|
|
|
@@ -245,7 +245,7 @@ AGENT.md ← workspace > global
|
|
|
245
245
|
## Your Team ← roster (dropped when team is empty/unset)
|
|
246
246
|
## User Instructions ← ~/.halo/global/INSTRUCTIONS.md (suppressed when ws has its own)
|
|
247
247
|
## User Instructions ← <ws>/.halo/INSTRUCTIONS.md (workspace root)
|
|
248
|
-
## Project Knowledge ← <ws>/.halo/INDEX.md (
|
|
248
|
+
## Project Knowledge ← <ws>/.halo/INDEX.md (skipped when absent)
|
|
249
249
|
"The project workspace is at: ..."
|
|
250
250
|
"Working directory: ..." ← if workingDir is set
|
|
251
251
|
rootPrompt ← prompts/root/*.md (ws > global): DELEGATION.md, WORKSPACE_MEMORY.md
|
|
@@ -403,4 +403,4 @@ Schema declared by each package + values stored centrally is the same model VSCo
|
|
|
403
403
|
- **Bootstrap trigger**: `!parentId && !userMd`
|
|
404
404
|
- **System prompts are external**: resolved from workspace then global `prompts/{bootstrap,all,root}/*.md`, read live, sorted by filename.
|
|
405
405
|
- **System prompt missing**: warn + use built-in fallback; `system-prompts.ts` is the seed source and fallback
|
|
406
|
-
- **`.halo/` is
|
|
406
|
+
- **`.halo/` is grep/globbed** except its runtime subtrees (`sessions/ logs/ evo/ tmp/ assets/ canvas/`)
|
|
@@ -82,9 +82,9 @@ Manages every agent session's lifecycle (root + sub-agent). Each session is 1:1
|
|
|
82
82
|
|
|
83
83
|
| Method | Purpose |
|
|
84
84
|
|---|---|
|
|
85
|
-
| `createSession(agentId, parentId, description, agentName?, explicitId?, workingDir?, accessLevel?)` | Create a session (SQLite + memory). `workingDir` = absolute path at runtime, stored as workspace-relative in DB; null = project root. `accessLevel` = `'readonly'`, `'workspace'`, or `null` (full). |
|
|
86
|
-
| `sendUserMessage(sessionId, text, images?)` | Send a message — run immediately if idle, queue if busy |
|
|
87
|
-
| `compactSession(sessionId)` | LLM-summary compact |
|
|
85
|
+
| `createSession(agentId, parentId, description, agentName?, explicitId?, workingDir?, accessLevel?, title?)` | Create a session (SQLite + memory). `workingDir` = absolute path at runtime, stored as workspace-relative in DB; null = project root. `accessLevel` = `'readonly'`, `'workspace'`, or `null` (full). |
|
|
86
|
+
| `sendUserMessage(sessionId, text, images?, accessLevel?)` | Send a message — run immediately if idle, queue if busy; returns `'running'` or `'queued'` |
|
|
87
|
+
| `compactSession(sessionId, opts?)` | LLM-summary compact (`opts.onProgress` for UI progress) |
|
|
88
88
|
| `interruptSession(sessionId)` | Abort the in-flight turn now (fire-and-forget); `interruptRequested` is set so the unwind repairs rather than errors, then the queued message drains. Shared by esc and the `interrupt_session` tool |
|
|
89
89
|
| `stopSession(sessionId)` | Fold the whole `messageQueue` into `agent.messages` (preserve, don't drop), abort + repair, no re-run, sets `stoppedAt`. Cascades to descendants |
|
|
90
90
|
| `deleteSession(sessionId)` | Cascade-delete a session and all descendants (SQLite) |
|
|
@@ -196,7 +196,7 @@ A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is
|
|
|
196
196
|
|
|
197
197
|
**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.
|
|
198
198
|
|
|
199
|
-
**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.
|
|
199
|
+
**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 / wecom) 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.
|
|
200
200
|
|
|
201
201
|
**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):
|
|
202
202
|
- 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.
|
|
@@ -205,7 +205,7 @@ A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is
|
|
|
205
205
|
|
|
206
206
|
**`complete` invariants** (root only):
|
|
207
207
|
- The **terminal** `complete` is emitted from `runSession`'s `finally`, and `promise = null` is set **before** emitting it — the CLI / web stream-close logic gates on `complete && !hasRunningSessions()`, which reads `promise !== null`; emitting `complete` first would leave the session still "running" at the moment the client decides whether to close.
|
|
208
|
-
- **`batchBoundary` complete**: when a merged round finishes **and the queue still has a next round**, `drainQueue` emits `{ type: 'complete', batchBoundary: true }`. This is a per-round flush signal for **block-oriented channels** (wechat / telegram / slack / feishu), which buffer streamed text and only ship a message on `complete` — without it, N drain rounds buffer into one blob that lands only at the terminal `complete` ("8 reports in one lump"). **Stream-terminating consumers (web-channel SSE, ACP) must ignore `batchBoundary` and keep the stream open** — the root is still running and more output follows; only the terminal (unmarked) `complete` closes the stream. See [web.md](web.md) and the per-channel coalescing notes (e.g. [wechat.md](wechat.md)). The
|
|
208
|
+
- **`batchBoundary` complete**: when a merged round finishes **and the queue still has a next round**, `drainQueue` emits `{ type: 'complete', batchBoundary: true }`. This is a per-round flush signal for **block-oriented channels** (wechat / telegram / slack / feishu / wecom), which buffer streamed text and only ship a message on `complete` — without it, N drain rounds buffer into one blob that lands only at the terminal `complete` ("8 reports in one lump"). **Stream-terminating consumers (web-channel SSE, ACP) must ignore `batchBoundary` and keep the stream open** — the root is still running and more output follows; only the terminal (unmarked) `complete` closes the stream. See [web.md](web.md) and the per-channel coalescing notes (e.g. [wechat.md](wechat.md)). The WS `chat:complete` frame carries the field too — admin closes its bubble either way but rings the finish bell only on the terminal one.
|
|
209
209
|
|
|
210
210
|
**Three-tier interrupt model** — two **soft** paths and one **hard** path, distinguished by *when* the abort fires, never by whether the message is kept (all three preserve the queue):
|
|
211
211
|
1. **`query_session` while busy — soft interrupt.** `querySession(..., interrupt=false)` enqueues **and** sets `interruptRequested` (same as a user message). This is what makes a sub-agent fold two queued questions into **one merged answer** instead of replying one-by-one: the in-flight turn unwinds after its current tool, then `drainQueue`'s `splice(0)` folds every message that landed alongside it into a single round — matching how root handles two user messages. An **idle** target has no turn to interrupt, so it just runs the message directly.
|
|
@@ -229,7 +229,7 @@ A hard interrupt aborts the turn's `AbortSignal`, which `agent-loop.ts` forwards
|
|
|
229
229
|
|
|
230
230
|
- **`full` access (non-Windows)** spawns via `spawn(command, { shell: true, detached: true })` so the command becomes a **process-group leader** (`pgid === child.pid`). On abort *or* timeout, `process.kill(-pid, 'SIGTERM')` signals the **whole group**, so a compound command's worker dies with the shell.
|
|
231
231
|
- **Why not `execAsync(command, { signal })`** (the previous implementation): `exec` wraps the command in `/bin/sh -c "<command>"` and, on abort, only SIGTERMs that `sh`. A compound command (`sleep 60 && …`) has already forked the real worker (`sleep`) as a child of `sh` — the signal never reaches it, so it **reparents to init (PPID 1) and runs to completion as an orphan**. The agent turn unwinds correctly (the promise rejects), but the command keeps running, which made `interrupt_session` *look* like it didn't really interrupt. Single non-compound commands didn't expose it (Node optimizes them to a direct `exec` with no `sh` layer).
|
|
232
|
-
- **`workspace` / `readonly` access** run under `bwrap` with `--die-with-parent`, a kernel-level guarantee that the sandboxed child dies with its parent — that path was never affected and is unchanged. **Windows** `full` keeps the `execAsync` path (no process groups; the orphan case doesn't arise the same way).
|
|
232
|
+
- **`workspace` / `readonly` access** run under `bwrap` with `--die-with-parent`, a kernel-level guarantee that the sandboxed child dies with its parent — that path was never affected and is unchanged. On macOS they run under Seatbelt (`sandbox-exec`) through the same `spawnGroupExec` group kill. **Windows** `full` keeps the `execAsync` path (no process groups; the orphan case doesn't arise the same way).
|
|
233
233
|
|
|
234
234
|
The `spawnGroupExec` helper preserves `promisify(exec)`'s contract exactly: resolve `{ stdout, stderr }` on exit 0; reject with an `Error` carrying `.message` / `.stdout` / `.stderr` / `.code` otherwise (abort rejects with `name: 'AbortError'`). Covered by `test/sandbox-process-group.test.ts`, which asserts by side effect — a sentinel file the orphaned worker *would* create must never appear after the abort.
|
|
235
235
|
|
|
@@ -375,7 +375,7 @@ Frontend network issues don't affect the backend:
|
|
|
375
375
|
| **4xx multimodal rejection** (`Multimodal data is corrupted` / `Could not process image`) | Replace all image blocks in history with text placeholders, persist, retry — see [Multimodal 4xx degrade](#multimodal-4xx-degrade) below |
|
|
376
376
|
| Unrecoverable error | Report to user, stop |
|
|
377
377
|
|
|
378
|
-
**Refusal stop.** `stop_reason: "refusal"` (Anthropic models on Bedrock invoke + generic anthropic) is an HTTP 200 with `stop_details {category, explanation}` — not an error, so it never enters the retry matrix above. `agent-loop.ts
|
|
378
|
+
**Refusal stop.** `stop_reason: "refusal"` (Anthropic models on Bedrock invoke + generic anthropic) is an HTTP 200 with `stop_details {category, explanation}` — not an error, so it never enters the retry matrix above. `agent-loop.ts` handles it explicitly: no assistant message is pushed, any partial `tool_use` is **not** executed, the loop ends; `session-manager.ts` emits a `system` event `⚠️ [<agent>] Model declined to respond (<category>): <explanation>` suggesting `/new`. Previously it fell through the `end_turn` path and looked like a silent stall.
|
|
379
379
|
|
|
380
380
|
**Classification order: structured signal first, message text last.** The interrupt branch is decided by the attempt's own `AbortController` signal (every interrupt path we own goes through `abortReason()` on it), never by the words `cancelled` / `aborted` in the message — an upstream error body can legitimately read `"The operation was aborted due to …"` (Bedrock), and matching that swallowed a real failure as a user interrupt: silent `break`, no retry, no error event, an empty reply. Likewise the account-level branch trusts `httpStatus` when one was recovered: 401/402/403 is terminal, any *other* status is not, whatever the body says — an OpenAI-compatible 503 whose body reads `"authentication service temporarily unavailable"` used to be classified as a dead key and never retried. The keyword list is only consulted when no status could be extracted. Pinned by `turn-retry-idempotent.test.ts` (five classification cases).
|
|
381
381
|
|
|
@@ -401,7 +401,7 @@ The transient-5xx branch is the one that all providers share, and getting the **
|
|
|
401
401
|
|
|
402
402
|
**Retry decision** — a failure is treated as a transient server-side error (→ retry with backoff) when **either**:
|
|
403
403
|
|
|
404
|
-
- `err.name` is `InternalServerException`, `ModelTimeoutException`, or `ServiceUnavailableException`; **or**
|
|
404
|
+
- `err.name` is `InternalServerException`, `ModelTimeoutException`, `ModelStreamErrorException`, or `ServiceUnavailableException`; **or**
|
|
405
405
|
- the extracted `httpStatus` is one of `500` / `502` / `503` / `504` / `529` (Anthropic "Overloaded") / `408` (request/model timeout).
|
|
406
406
|
|
|
407
407
|
Throttling is checked the same way one branch earlier — structured `ThrottlingException` name or status 429 first, message keywords second; the real Bedrock throttle message ("Too many requests, please wait before trying again.") carries no keyword, so the name/status check is what catches it.
|
|
@@ -418,7 +418,7 @@ Three entry points with different quality / safety trade-offs:
|
|
|
418
418
|
|---|---|---|---|
|
|
419
419
|
| 90% 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. |
|
|
420
420
|
| 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. |
|
|
421
|
-
| User `/session compact` (web, WeChat) | `commands/
|
|
421
|
+
| User `/session compact` (web, WeChat) | `channels/shared/commands.ts` (admin WS: `ws/handler.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`. |
|
|
422
422
|
|
|
423
423
|
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.
|
|
424
424
|
|
|
@@ -426,7 +426,7 @@ The pre-run snapshot is load-bearing, not an optimization: `agent.run()` coalesc
|
|
|
426
426
|
|
|
427
427
|
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.
|
|
428
428
|
|
|
429
|
-
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 `
|
|
429
|
+
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 `keep_messages` 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 <= keep_messages` "nothing to compact" case.
|
|
430
430
|
|
|
431
431
|
**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.
|
|
432
432
|
|
|
@@ -438,7 +438,7 @@ All paths share the same split logic — `compactCut(messages)`, exported from `
|
|
|
438
438
|
|
|
439
439
|
**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.
|
|
440
440
|
|
|
441
|
-
Config (see `config.compact`): `
|
|
441
|
+
Config (see `config.compact`): `keep_messages` / `max_summary_input` / `max_message_slice` (settings keys `general.compact.*`) — editable in Settings → General → compact.
|
|
442
442
|
|
|
443
443
|
**Self-compact also archives the UI log.** `selfCompactSession` calls `uiStore.archiveOldMessages(session.id)` for **root and sub-sessions alike** (root-only until 1.5.3-alpha; a long-running sub-agent's UI log then grew without bound — 4.77 MB in testing), 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).
|
|
444
444
|
|
|
@@ -464,7 +464,7 @@ Files: `packages/server/src/sessions/session-archive.ts` (segment IO) · `agents
|
|
|
464
464
|
|
|
465
465
|
**Trigger: file size.** A compact archives only when the active `<seg>.json` is larger than `ARCHIVE_SIZE_THRESHOLD = 3 MB` (one `statSync`; a missing file counts as 0, so the check is also the "nothing written yet" guard). Below it, `archiveOldMessages` returns 0 and touches nothing, however many exchanges have piled up.
|
|
466
466
|
|
|
467
|
-
Bytes, not an exchange count, because bytes are the actual problem — file size is what makes a log slow to read, parse and ship — and per-exchange size varies by ~three orders of magnitude (a bare "yes" versus a turn carrying several 50 KB tool results), so a count is a badly distorted proxy: the same "15 exchanges" is 40 KB in one session and 40 MB in another. It is also self-limiting: after an archive the file restarts near-empty, so it must grow another 3 MB of real content before the next segment, which bounds segment count by total bytes written rather than by how often the user compacts. Still no relation to `config.compact.
|
|
467
|
+
Bytes, not an exchange count, because bytes are the actual problem — file size is what makes a log slow to read, parse and ship — and per-exchange size varies by ~three orders of magnitude (a bare "yes" versus a turn carrying several 50 KB tool results), so a count is a badly distorted proxy: the same "15 exchanges" is 40 KB in one session and 40 MB in another. It is also self-limiting: after an archive the file restarts near-empty, so it must grow another 3 MB of real content before the next segment, which bounds segment count by total bytes written rather than by how often the user compacts. Still no relation to `config.compact.keep_messages` (that counts raw LLM messages — a different granularity with no honest mapping to UI rows), and no setting: one constant, tuned once.
|
|
468
468
|
|
|
469
469
|
**Split point.** Over the threshold, `archiveSplitIndex(messages, 1)` keeps only the **newest main user exchange** (`role === 'user'` without `taskId` — the same subset the admin renders and `deleteExchange` counts ordinals over) and cuts at its start, so a turn's responses are never split from their user message. A log holding a single exchange yields cut 0 and is left alone: there is nothing to move without splitting the turn the user is looking at, so an oversized *single* exchange is deliberately not archived.
|
|
470
470
|
|
|
@@ -61,7 +61,7 @@ Files: `packages/server/src/channels/slack/`
|
|
|
61
61
|
- `types.ts` — SlackAccount, SlackSocketEnvelope, SlackMessageEvent interfaces
|
|
62
62
|
- `accounts.ts` — DAL (thin wrapper over `channels/shared/accounts.ts` for Slack-specific mappings)
|
|
63
63
|
- `handler.ts` — Socket Mode connection + event routing, mention filtering; session resolution + per-thread listener/route bookkeeping come from `channels/shared/inbound.ts` (`InboundBridge` / `deliverInbound` / `dispatchChannelCommand`)
|
|
64
|
-
- `event-adapter.ts` — AgentSessionEvent → Slack messages (buffer + flush
|
|
64
|
+
- `event-adapter.ts` — AgentSessionEvent → Slack messages (buffer + flush on `complete`; split at 35k chars)
|
|
65
65
|
- `api.ts` — HTTP client (auth.test / openSocketModeConnection / chat.postMessage / files.upload / files.download / searchSlackTargets)
|
|
66
66
|
- `cron-dispatcher.ts` — CronDispatcher registration; requires an explicit Slack chatId per dispatch
|
|
67
67
|
|
|
@@ -109,13 +109,13 @@ Unlike Telegram (grammy polling) or WeChat (HTTP long-poll), Socket Mode elimina
|
|
|
109
109
|
|
|
110
110
|
1. SessionManager emits AgentSessionEvent stream chunks
|
|
111
111
|
2. SlackResponder buffers only chunks flagged `final` (the turn's wrap-up text; pre-tool-call filler is dropped) until `complete` event
|
|
112
|
-
3. On `complete`
|
|
113
|
-
- Split at paragraph boundary if
|
|
112
|
+
3. On `complete` (Slack doesn't set `splitMidStream`, so the buffer is never flushed early at the limit):
|
|
113
|
+
- Split at the 35k-char limit, at a paragraph boundary if possible, via the shared `splitText` (`channels/shared/chunk.ts`; Slack hard-caps messages at ~40k)
|
|
114
114
|
- Extract `MEDIA: <path>` markers (sent as native file uploads)
|
|
115
115
|
- Convert CommonMark → Slack's mrkdwn format
|
|
116
116
|
- Post as a single message (or series of messages if split)
|
|
117
117
|
- This fires on **any** `complete`; the responder ignores its `batchBoundary` flag, so a multi-round queue drain posts each merged turn as its own message rather than one blob (see [session.md](session.md#message-queue-and-drain))
|
|
118
|
-
4. Errors and system notices flush immediately (don't wait for complete)
|
|
118
|
+
4. Errors (`❌ …`) and system notices (`ℹ️ …`) flush immediately (don't wait for complete)
|
|
119
119
|
5. Sub-agent events (taskId set) are dropped (visible in web UI only)
|
|
120
120
|
|
|
121
121
|
**Chunk sends are serialized per responder.** One `flushBuffer` can produce
|
|
@@ -125,7 +125,9 @@ previous one, so Slack could accept them out of order and a long reply arrived
|
|
|
125
125
|
scrambled (audit A-L3). Each responder now keeps a `sendTail` promise chain and
|
|
126
126
|
appends every chunk to it, so chunk *n+1* only goes out after *n* settles; a
|
|
127
127
|
rejected link is absorbed (`dispatchChunk` logs its own send failures) so one
|
|
128
|
-
bad send can't stall the rest of the reply.
|
|
128
|
+
bad send can't stall the rest of the reply. The buffer + chain live in the
|
|
129
|
+
shared `ChunkedResponder` (`channels/shared/responder.ts`), which slack / feishu
|
|
130
|
+
/ wecom / wechat / telegram extend with their own limit and formatter.
|
|
129
131
|
|
|
130
132
|
`close()` therefore returns that drain promise, and `InboundBridge` keeps the
|
|
131
133
|
session's reply route registered until it settles before deleting it — including
|
|
@@ -138,20 +140,20 @@ dropped.
|
|
|
138
140
|
|
|
139
141
|
### DM sessions
|
|
140
142
|
|
|
141
|
-
DM channels have a stable channel_id (`D…`). A message in a DM stays in the same session regardless of turn count. Session
|
|
143
|
+
DM channels have a stable channel_id (`D…`). A message in a DM stays in the same session regardless of turn count. Session prefix: `slack_${channelId}:dm_`.
|
|
142
144
|
|
|
143
145
|
### Channel / group / thread sessions
|
|
144
146
|
|
|
145
|
-
A mention on a top-level message starts a thread and creates a session keyed by that message's `ts`. Replies inside that thread reuse the same `thread_ts`, so they map to the same session and preserve context. Session
|
|
147
|
+
A mention on a top-level message starts a thread and creates a session keyed by that message's `ts`. Replies inside that thread reuse the same `thread_ts`, so they map to the same session and preserve context. Session prefix: `slack_${channelId}:${rootTs}_`.
|
|
146
148
|
|
|
147
149
|
### Session resolution
|
|
148
150
|
|
|
149
|
-
|
|
150
|
-
1. Build session prefix
|
|
151
|
-
2.
|
|
152
|
-
3.
|
|
153
|
-
|
|
154
|
-
|
|
151
|
+
`handleInbound()` hands the message to `deliverInbound` (`channels/shared/inbound.ts`), which calls `getOrCreateChannelSession`:
|
|
152
|
+
1. Build the session prefix with `buildSessionPrefixForThread(channelId, rootTs)` (see above; `rootTs` is `dm` for DMs); the override key is `${userId}@${channelId}:${rootTs}`
|
|
153
|
+
2. `findActiveSessionId` (`channels/shared/commands.ts`): honour the per-user activeOverrides entry (set by `!session switch` in DMs), else the latest session under the prefix
|
|
154
|
+
3. If none found, create one with ID `${prefix}${Date.now().toString(36)}` on the workspace's default agent and store it in activeOverrides (`setOverrideOnCreate`)
|
|
155
|
+
|
|
156
|
+
**Reply route after a restart.** Routes and listeners are in-memory, so `startAccount` calls `restoreReplyRoute()`: it reads `lastActiveChatId` (`<channelId>:<rootTs>`) and re-wires the latest existing session under that prefix via `restoreChannelRoute` (no session is created), so a turn that resumes on its own after a restart still reaches Slack. Only that one conversation per account is restored; other threads re-wire on their next inbound message.
|
|
155
157
|
|
|
156
158
|
## Cron-dispatcher role
|
|
157
159
|
|
|
@@ -203,7 +205,7 @@ Text, images (base64 attached to agent input for vision), files. All files are d
|
|
|
203
205
|
|
|
204
206
|
The agent runtime produces `MEDIA: <path>` markers. SlackResponder:
|
|
205
207
|
- Intercepts the markers and calls `uploadFile()` for each one
|
|
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
|
|
208
|
+
- 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 (`sendMediaOrReport` in `channels/shared/media.ts`), so it surfaces as the `handler.upload_failed` text below
|
|
207
209
|
- Falls back to error text if upload fails
|
|
208
210
|
|
|
209
211
|
Outbound media uses Slack's v2 upload flow (getUploadURLExternal → signed PUT → completeUploadExternal).
|
|
@@ -218,9 +220,9 @@ Not supported: interactive messages (blocks/buttons/callbacks), slash commands i
|
|
|
218
220
|
|
|
219
221
|
Slack-specific paths:
|
|
220
222
|
|
|
221
|
-
- `packages/server/src/channels/slack/handler.ts
|
|
222
|
-
- `packages/server/src/channels/slack/handler.ts
|
|
223
|
-
- `packages/server/src/channels/slack/handler.ts
|
|
224
|
-
- `packages/server/src/channels/slack/api.ts
|
|
225
|
-
- `packages/server/src/channels/slack/cron-dispatcher.ts
|
|
226
|
-
- `packages/server/src/channels/
|
|
223
|
+
- `packages/server/src/channels/slack/handler.ts` `connect()` (inside `startSlackChannel()`) — Socket Mode setup
|
|
224
|
+
- `packages/server/src/channels/slack/handler.ts` `handleInbound()` — event dispatch
|
|
225
|
+
- `packages/server/src/channels/slack/handler.ts` `buildSessionPrefixForThread()` + `packages/server/src/channels/shared/inbound.ts` `deliverInbound()` / `getOrCreateChannelSession()` — session keying + resolution
|
|
226
|
+
- `packages/server/src/channels/slack/api.ts` `openSocketModeConnection()` — fetch wss:// URL
|
|
227
|
+
- `packages/server/src/channels/slack/cron-dispatcher.ts` `dispatch()` — cron push logic
|
|
228
|
+
- `packages/server/src/channels/shared/responder.ts` — `ChunkedResponder.dispatchChunk()` buffer flush + media extraction (`slack/event-adapter.ts` supplies the 35k limit + `formatForSlack`)
|
|
@@ -11,8 +11,8 @@ Defines the persisted-data format for every Halo surface. Format changes must re
|
|
|
11
11
|
│ ├── USER.md # User profile (bootstrap-generated)
|
|
12
12
|
│ ├── prompts/ # User-editable system prompts (externalised)
|
|
13
13
|
│ │ ├── bootstrap/BOOTSTRAP.md # First-run guidance
|
|
14
|
-
│ │ ├── all/ # Every agent (TOOL_GUIDELINES.md, TOOL_SHELL[.windows].md)
|
|
15
|
-
│ │ └── root/ # Root agent only
|
|
14
|
+
│ │ ├── all/ # Every agent (TOOL_GUIDELINES.md, TOOL_SHELL[.windows].md, WORKSPACE_CONVENTIONS.md, RUNTIME.md)
|
|
15
|
+
│ │ └── root/ # Root agent only (DELEGATION.md, WORKSPACE_MEMORY.md)
|
|
16
16
|
│ ├── agents/<id>/
|
|
17
17
|
│ │ ├── agent.yaml # Agent config
|
|
18
18
|
│ │ └── AGENT.md # Agent personality
|
|
@@ -26,7 +26,8 @@ Defines the persisted-data format for every Halo surface. Format changes must re
|
|
|
26
26
|
│ ├── evo.db # Cross-workspace evolution queue (evolution_runs + evolution_applies)
|
|
27
27
|
│ ├── cron.db # Cross-workspace cron jobs + run history (cron_jobs + cron_runs)
|
|
28
28
|
│ ├── runs.db # Run ledger: (workspace, session_id) rows for server-driven runs in flight; steady state empty (running_sessions)
|
|
29
|
-
│ ├──
|
|
29
|
+
│ ├── extensions/<id>/ # Installed Canvas preview extensions (halo-extension.json + bundle)
|
|
30
|
+
│ ├── logs/ # Runtime logs (server.log)
|
|
30
31
|
│ │ ├── evo/ # Per-evo-run wrapper logs
|
|
31
32
|
│ │ └── cron/ # Per-cron-run cli stdout/stderr (30-day retention)
|
|
32
33
|
│ ├── server.lock # Single-instance pid lock
|
|
@@ -62,7 +63,7 @@ Defines the persisted-data format for every Halo surface. Format changes must re
|
|
|
62
63
|
├── tmp/ # Agent scratch files (logs, downloads, intermediate artifacts) — convention from TOOL_GUIDELINES
|
|
63
64
|
├── assets/<channel>/inbound/<accountId>/<date>/ # Inbound media per channel (image/voice/video/file)
|
|
64
65
|
├── runtime.lock # Workspace runtime ownership marker (pid) — see below
|
|
65
|
-
├── halo.db # Per-workspace sqlite (sessions metadata,
|
|
66
|
+
├── halo.db # Per-workspace sqlite (sessions metadata, disabled-items)
|
|
66
67
|
└── docs/ # Project docs (requirements/design/dev/test/plans)
|
|
67
68
|
```
|
|
68
69
|
|
|
@@ -148,11 +149,11 @@ type MessageType =
|
|
|
148
149
|
interface SessionMessage {
|
|
149
150
|
// Required
|
|
150
151
|
id: string // "m_{timestamp36}_{counter36}"
|
|
151
|
-
type
|
|
152
|
+
type?: MessageType // optional for backward compat (inferMessageType)
|
|
152
153
|
role: 'user' | 'assistant' | 'system'
|
|
153
154
|
content: string
|
|
154
155
|
timestamp: number // Unix ms
|
|
155
|
-
agentName
|
|
156
|
+
agentName?: string
|
|
156
157
|
|
|
157
158
|
// Optional scope
|
|
158
159
|
taskId?: string // Sub-agent task ID
|
|
@@ -181,6 +182,9 @@ interface SessionMessage {
|
|
|
181
182
|
|
|
182
183
|
// Transient (not persisted)
|
|
183
184
|
streaming?: boolean
|
|
185
|
+
|
|
186
|
+
// Soft delete (deleteExchange) — greyed out in the UI, removed from raw context
|
|
187
|
+
deleted?: boolean
|
|
184
188
|
}
|
|
185
189
|
```
|
|
186
190
|
|
|
@@ -287,12 +291,15 @@ interface AgentYamlConfig {
|
|
|
287
291
|
maxTokens?: number
|
|
288
292
|
promptCaching?: boolean | string
|
|
289
293
|
thinking?: { enabled?: boolean; budget?: string; effort?: string }
|
|
294
|
+
verbosity?: string
|
|
290
295
|
}
|
|
291
296
|
system_prompt?: string
|
|
292
297
|
tools?: string[]
|
|
293
298
|
skills?: string[]
|
|
294
299
|
context?: { maxTokens?: number; compressAt?: number }
|
|
295
300
|
priority?: number
|
|
301
|
+
internal?: boolean // platform tooling — off the roster, no workspace context
|
|
302
|
+
team?: string[] // delegation whitelist; non-empty = can delegate
|
|
296
303
|
}
|
|
297
304
|
```
|
|
298
305
|
|
|
@@ -335,7 +342,7 @@ general: # built-in declarer (the server itself
|
|
|
335
342
|
max_summary_input: 15000
|
|
336
343
|
max_message_slice: 800
|
|
337
344
|
sandbox:
|
|
338
|
-
hidden_dirs: "~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh"
|
|
345
|
+
hidden_dirs: "~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs"
|
|
339
346
|
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"
|
|
340
347
|
logging:
|
|
341
348
|
level: warn
|
|
@@ -388,7 +395,6 @@ A value of the form `<<ENV_NAME>>` is replaced at read time with `process.env.EN
|
|
|
388
395
|
| `general.limits.grep_default_matches` | 50 | Default `grep` match cap when no explicit `max` is passed |
|
|
389
396
|
| `general.limits.tool_result_render_chars` | 8000 | Per-tool-result cap on the content **fed to the LLM** (truncated with a re-run marker to protect the context window / prompt cache). The UI gets a much larger slice — see `tool_result_ui_chars` and `agent-loop.ts` (`resultContent` = LLM cap, `resultTextFull` = UI cap) |
|
|
390
397
|
| `general.limits.tool_result_ui_chars` | 65536 | Per-tool-result cap on the content **stored for UI display** (admin/web chat panel). Far larger than the LLM cap so a normal command's full output stays visible, but bounded so a multi-MB `cat` can't bloat the session file / WS payload / browser render; excess truncated with a marker pointing at `file_read` |
|
|
391
|
-
| `general.limits.ws_event_buffer` | 5000 | Events buffered per detached WS session before oldest are dropped on reattach |
|
|
392
398
|
| `general.limits.terminal_scrollback_bytes` | 50000 | Off-screen scrollback bytes retained per detached persistent terminal |
|
|
393
399
|
| `general.sandbox.hidden_dirs` | `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs` | Hidden from workspace/readonly sessions — bwrap tmpfs overlay (Linux) / Seatbelt deny (macOS) |
|
|
394
400
|
| `general.sandbox.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` | Hidden from workspace/readonly sessions — bwrap bind of the empty `~/.halo/.sandbox-empty`, reads as empty (Linux) / Seatbelt deny (macOS) |
|
|
@@ -536,6 +542,8 @@ interface SkillMeta {
|
|
|
536
542
|
name: string // from frontmatter
|
|
537
543
|
description: string // from frontmatter
|
|
538
544
|
path: string // absolute path to SKILL.md
|
|
545
|
+
command?: string // from frontmatter
|
|
546
|
+
requiresAccess?: 'full' | 'workspace' | 'readonly' // from frontmatter
|
|
539
547
|
}
|
|
540
548
|
```
|
|
541
549
|
|
|
@@ -552,16 +560,6 @@ The databases hold session metadata indexes and workspace-scoped preferences (e.
|
|
|
552
560
|
|
|
553
561
|
### Tables
|
|
554
562
|
|
|
555
|
-
**`sessions`** — frontend session index (created on first chat)
|
|
556
|
-
|
|
557
|
-
| Column | Type | Notes |
|
|
558
|
-
|---|---|---|
|
|
559
|
-
| id | TEXT PK | Session ID |
|
|
560
|
-
| title | TEXT NOT NULL DEFAULT '' | Truncated first user message |
|
|
561
|
-
| messages | TEXT | Legacy field, unused (content now in JSON) |
|
|
562
|
-
| message_count | INTEGER | Message count |
|
|
563
|
-
| created_at / updated_at | INTEGER | Unix ms |
|
|
564
|
-
|
|
565
563
|
**`agent_sessions`** — agent session index (root + sub-sessions)
|
|
566
564
|
|
|
567
565
|
| Column | Type | Notes |
|
|
@@ -602,7 +600,7 @@ All channel types (telegram, web, wechat, slack, feishu, wecom) share one table.
|
|
|
602
600
|
| Column | Type | Notes |
|
|
603
601
|
|---|---|---|
|
|
604
602
|
| account_id | TEXT PK | Channel-specific ID (e.g. `halo_agent_bot`, `abc-im-bot`, `e718bb7b`) |
|
|
605
|
-
| channel_type | TEXT NOT NULL | `'telegram'`, `'web'`, `'wechat'`, `'slack'`, or `'
|
|
603
|
+
| channel_type | TEXT NOT NULL | `'telegram'`, `'web'`, `'wechat'`, `'slack'`, `'feishu'`, or `'wecom'` |
|
|
606
604
|
| workspace_path | TEXT NOT NULL | Absolute path of the bound workspace |
|
|
607
605
|
| label | TEXT | User-chosen name |
|
|
608
606
|
| enabled | INTEGER | 1 = active, 0 = disabled |
|
|
@@ -620,6 +618,7 @@ All channel types (telegram, web, wechat, slack, feishu, wecom) share one table.
|
|
|
620
618
|
| wechat | `botToken`, `baseUrl`, `userId`, `syncBuf` |
|
|
621
619
|
| slack | `botToken`, `appToken`, `botUserId`, `teamId` |
|
|
622
620
|
| feishu | `appId`, `appSecret`, `verificationToken`, `encryptKey`, `botOpenId` |
|
|
621
|
+
| wecom | `botId`, `secret` |
|
|
623
622
|
|
|
624
623
|
### Schema change rules
|
|
625
624
|
|
|
@@ -17,11 +17,11 @@ All channels are peers — each one is a subscriber + caller against SessionMana
|
|
|
17
17
|
|
|
18
18
|
## Shared inbound skeleton
|
|
19
19
|
|
|
20
|
-
All
|
|
20
|
+
All five IM channels (telegram / wechat / slack / feishu / wecom) route inbound messages through `channels/shared/inbound.ts` rather than each keeping its own copy of the same ~200-line tail. The copies had drifted independently, which is how two audited bugs happened (audit A, 2026-08-06): the wechat responder captured its recipient at listener-registration time (A-M2), and all the command-dispatch sites ignored `CommandResult.startedTurn`, so a skill command on a fresh session kicked the agent with no listener attached and the reply vanished (A-M5).
|
|
21
21
|
|
|
22
22
|
Three pieces:
|
|
23
23
|
|
|
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()`
|
|
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()` returns a promise (every responder extends the shared `ChunkedResponder`, which serializes its per-chunk sends and hands the chain's tail back), 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
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
|
|
|
@@ -37,6 +37,7 @@ One bot (= one BotFather token) is bound to one workspace. A workspace can bind
|
|
|
37
37
|
|
|
38
38
|
- **One Telegram user → many sessions (one active at a time)**
|
|
39
39
|
- Session ID format: `tg_<userId>_<createdAtBase36>` (e.g. `tg_123456789_m1abc`)
|
|
40
|
+
- Keyed by the **sender** (`from.id`), not the chat: a user's DM and every group they talk to the bot in share the same sessions (see [Group chats](#group-chats))
|
|
40
41
|
- Sessions live under the bot's bound workspace and use the highest-priority agent (falls back to `default` only when none exists)
|
|
41
42
|
- The web side sees these sessions in the session list (labelled `Telegram: <userId>`)
|
|
42
43
|
- Access level inherited from the bot account
|
|
@@ -61,7 +62,7 @@ Telegram-specific config JSON fields: `botToken`, `botUsername`, `allowedUsers`,
|
|
|
61
62
|
|
|
62
63
|
Halo can send to a Telegram chat without that user having messaged the bot first — Telegram's Bot API allows `sendMessage(chatId, text)` for any known chat id. Halo only requires that the chat id come from a *trusted source*.
|
|
63
64
|
|
|
64
|
-
The telegram cron-dispatcher (`channels/telegram/cron-dispatcher.ts`) registers itself with `cron/dispatcher.ts`'s registry at boot and **requires an explicit `chatId`** (numeric — Telegram private-chat ids equal user ids):
|
|
65
|
+
The telegram cron-dispatcher (`channels/telegram/cron-dispatcher.ts`) registers itself with `cron/dispatcher.ts`'s registry at boot and **requires an explicit `chatId`** (numeric — Telegram private-chat ids equal user ids, group ids are negative):
|
|
65
66
|
|
|
66
67
|
- Cron jobs created from inside a telegram chat via the `cron` skill auto-pin the current chat id (target `telegram:<account>:<chatId>`).
|
|
67
68
|
- Admin-UI cron jobs that don't specify a target run silently — the result shows in the cron log, nothing is pushed.
|
|
@@ -69,6 +70,8 @@ The telegram cron-dispatcher (`channels/telegram/cron-dispatcher.ts`) registers
|
|
|
69
70
|
|
|
70
71
|
Earlier drafts considered "fan-out to every id in `allowedUsers`" or "fall back to `lastActiveChatId`", but in practice both pushed the cron output to strangers' chats. The cron creator's intent is "reach me, the person who set this up" — so the only target is what the caller passes explicitly.
|
|
71
72
|
|
|
73
|
+
The report is chunked like a chat reply — `splitText(text, TELEGRAM_TEXT_LIMIT)` (4000 chars, exported from `event-adapter.ts`; the Bot API rejects a message over 4096) — and the chunks are sent sequentially so a long report arrives in order. Before this the whole report went out in one `sendMessage` and anything over the cap failed outright. A failed chunk stops the send and is recorded as `chunk i/n: <error>` in the target's result row (chunks before it were delivered and stay); full success is one `ok` row regardless of chunk count. Same contract as the wechat dispatcher (see [cron.md](cron.md#dispatch-model)).
|
|
74
|
+
|
|
72
75
|
## Modules
|
|
73
76
|
|
|
74
77
|
Files: `packages/server/src/channels/telegram/`
|
|
@@ -102,6 +105,20 @@ Routes: `packages/server/src/routes/telegram.ts`
|
|
|
102
105
|
|
|
103
106
|
Photos / documents / voice / video notes trigger the same flow with the saved-file note in the text.
|
|
104
107
|
|
|
108
|
+
Builtin commands (`bot.command`) run the same `allowedUsers` check first and reply `handler.not_allowed` on a miss — before, only plain messages were gated, so any member of a group the bot sat in could run `/session` / `/workspace switch` at the account's access level. A command with no sender (`channel_post` in a channel the bot administers) is ignored.
|
|
109
|
+
|
|
110
|
+
### Group chats
|
|
111
|
+
|
|
112
|
+
Supported, with Telegram's own visibility rules deciding what arrives:
|
|
113
|
+
|
|
114
|
+
- **What the bot receives** — Telegram, not halo, filters. Bots default to *privacy mode* in groups: only `/cmd@thisbot`, replies to the bot's messages, and a few edge cases are delivered; disable privacy mode in BotFather (`/setprivacy`) or make the bot a group admin to receive every message. Halo has no @-mention gate of its own.
|
|
115
|
+
- **Session isolation: per user.** The session prefix is `tg_<from.id>_` and the `activeOverrides` key is the user id, so each member gets their own session, and it is the *same* session they use in DM.
|
|
116
|
+
- **Reply destination: the chat the message came from.** The route is `{ chatId: ctx.chat.id }`, so a group message is answered in the group. The route is refreshed per message — a reply follows the user's latest chat (DM or group).
|
|
117
|
+
- **Permission check: the sender id.** `allowedUsers` matches `from.id` / `from.username`, never the chat id, for messages and commands alike.
|
|
118
|
+
- **Commands** — `bot.command` matches `/cmd@thisbot` for builtins; the skill-command path strips an `@<this bot>` suffix the same way (a suffix naming another bot is left alone and falls through to chat).
|
|
119
|
+
- **Restart restore** — `lastActiveChatId` stores the chat id; a group id is negative and doesn't name the member whose session it was, so `restoreReplyRoute` only restores private chats (`^\d+$`).
|
|
120
|
+
- **Cron** — the dispatcher accepts any numeric chat id, negative group ids included; a cron created from inside a group auto-pins the group.
|
|
121
|
+
|
|
105
122
|
## Slash commands (native Telegram /commands)
|
|
106
123
|
|
|
107
124
|
| Command | Purpose |
|
|
@@ -117,10 +134,11 @@ Photos / documents / voice / video notes trigger the same flow with the saved-fi
|
|
|
117
134
|
|
|
118
135
|
## Event coalescing (TelegramResponder)
|
|
119
136
|
|
|
120
|
-
Same strategy as WeChat:
|
|
137
|
+
Same strategy as WeChat — `TelegramResponder` is the shared `ChunkedResponder` (`channels/shared/responder.ts`) with `{ limit: 4000, logTag: 'Telegram', splitMidStream: true }`:
|
|
121
138
|
- Buffer only `stream` events flagged `final` (the turn's wrap-up text; the filler the model emits before a tool call is dropped) until `complete`, then flush as one message
|
|
122
139
|
- Flush happens on **any** `complete` — the responder doesn't read its `batchBoundary` flag, so a multi-round queue drain ships each merged turn as its own message instead of one blob (see [session.md](session.md#message-queue-and-drain))
|
|
123
|
-
- If buffer exceeds 4000 chars (Telegram limit), split at paragraph boundary via the shared `splitText` (`channels/shared/chunk.ts
|
|
140
|
+
- If buffer exceeds 4000 chars (Telegram limit), split at paragraph boundary via the shared `splitText` (`channels/shared/chunk.ts`), mid-stream and again on flush
|
|
141
|
+
- Every chunk goes through one serialized send chain, so a long reply arrives in order; `close()` returns the drain promise. Before this the telegram responder fired each chunk with `void dispatchChunk`, so the `sendMessage` calls raced and a >4000-char reply could land shuffled (the A-L3 fix had covered slack / feishu / wecom / wechat only)
|
|
124
142
|
- `MEDIA:<path>` markers are intercepted and sent as native Telegram media
|
|
125
143
|
- Errors flush immediately
|
|
126
144
|
- Sub-agent events are dropped (visible in web UI only)
|
|
@@ -138,7 +156,7 @@ Outbound: the `send-file` skill produces `MEDIA:<path>` markers. The responder s
|
|
|
138
156
|
- `.ogg/.oga` → `sendVoice`
|
|
139
157
|
- anything else → `sendDocument`
|
|
140
158
|
|
|
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.
|
|
159
|
+
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')` (`sendMediaOrReport` in `channels/shared/media.ts`) (`⚠️ 文件上传失败:name — error`), same as slack / feishu / wecom; before this it was only a server log line.
|
|
142
160
|
|
|
143
161
|
## Configuration
|
|
144
162
|
|
|
@@ -170,6 +188,6 @@ curl -X POST http://localhost:9527/api/telegram/accounts \
|
|
|
170
188
|
|
|
171
189
|
## Scope and out-of-scope
|
|
172
190
|
|
|
173
|
-
Supported: private chat text, photos, documents; slash commands; per-session access level; user whitelist; media sending.
|
|
191
|
+
Supported: private and group chat text, photos, documents; slash commands; per-session access level; user whitelist; media sending.
|
|
174
192
|
|
|
175
|
-
Not supported:
|
|
193
|
+
Not supported: inline queries, callback buttons, webhook mode (polling only).
|
|
@@ -91,7 +91,9 @@ data: {"type":"complete"}
|
|
|
91
91
|
data: {"type":"error","error":"..."}
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
> **Batch-boundary `complete` (must be absorbed, never closes the stream)**: when a root session drains a queue of multiple messages, the server runs N merged turns and emits an internal `complete` with `batchBoundary: true` between rounds (see [session.md](session.md#message-queue-and-drain)). The SSE generators in `channels/web/handler.ts` flush the just-finished round's text on a `batchBoundary` complete but **do not** send a `complete` SSE frame and **do not** set `done` — the response stays open for the next round; only the **terminal** (unmarked) `complete` closes the HTTP stream. Without this guard a producer→sub-agent fan-out would truncate the web client after the first round. The `batchBoundary` flag is therefore an internal server-side event marker only — it is never serialized into the SSE payload a client sees, so a custom frontend just consumes one ordinary `complete` at the end. (ACP rides on this channel, so it inherits the same safe behavior.)
|
|
94
|
+
> **Batch-boundary `complete` (must be absorbed, never closes the stream)**: when a root session drains a queue of multiple messages, the server runs N merged turns and emits an internal `complete` with `batchBoundary: true` between rounds (see [session.md](session.md#message-queue-and-drain)). The SSE generators in `channels/web/handler.ts` (`listenSession().events()` plus the `createMediaBuffer()` event processor) flush the just-finished round's text on a `batchBoundary` complete but **do not** send a `complete` SSE frame and **do not** set `done` — the response stays open for the next round; only the **terminal** (unmarked) `complete` closes the HTTP stream. Without this guard a producer→sub-agent fan-out would truncate the web client after the first round. The `batchBoundary` flag is therefore an internal server-side event marker only — it is never serialized into the SSE payload a client sees, so a custom frontend just consumes one ordinary `complete` at the end. (ACP rides on this channel, so it inherits the same safe behavior.)
|
|
95
|
+
|
|
96
|
+
> **Listener lifetime**: `handleMessage` registers its session listener (`listenSession`) *before* the media save and `sendUserMessage` — it must be listening when the turn starts — and wraps everything from there to the end of `events()` in a `try/finally` that closes it. So a throw in between (media save failed, `sendUserMessage` threw), a `queued` result, or the end of the stream all drop the listener; before, only `events()`'s own finally and the `queued` branch did, and an early throw leaked it for the process lifetime. The unsubscribe is idempotent, so closing twice on the normal path is harmless. The route passes the request's abort signal (`c.req.raw.signal`) through to `events(signal)` — for `/web/chat` and a skill command's follow-on stream, as `/web/subscribe` already did — so a client that disconnects mid-turn ends the stream and drops the listener right away instead of at the turn's terminal `complete`. The turn itself keeps running; disconnecting is not `/web/stop`.
|
|
95
97
|
|
|
96
98
|
#### Per-request overrides (`workspace`, `sessionId`, `agentId`)
|
|
97
99
|
|