@turmind/halo 1.5.3-alpha → 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 +11 -10
- 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 +19 -21
- 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-c6c7772db8a50926.js +0 -1
- package/admin-out/_next/static/chunks/app/page-5f41b086cd5ad353.js +0 -1
- package/admin-out/_next/static/css/8809d33379458514.css +0 -3
- /package/admin-out/_next/static/{Wqi_ugna9MYsiV7ECuawQ → cuVcldfdg1PXevaQMQSzb}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{Wqi_ugna9MYsiV7ECuawQ → cuVcldfdg1PXevaQMQSzb}/_ssgManifest.js +0 -0
|
@@ -104,6 +104,8 @@ loop {
|
|
|
104
104
|
3. If the text begins with `/`, dispatch a slash command (see below) — if it returns `handled`, stop here
|
|
105
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)
|
|
106
106
|
|
|
107
|
+
**After a restart**, `startAccount` calls `restoreReplyRoutes()` before the loop starts: every user with a stored `contextTokens` entry gets a reply route + listener on their latest existing session (`restoreChannelRoute`, no session created), so a turn that resumes on its own still reaches WeChat.
|
|
108
|
+
|
|
107
109
|
## Slash commands
|
|
108
110
|
|
|
109
111
|
Implemented in `handler.ts`'s `handleSlashCommand()`:
|
|
@@ -124,12 +126,12 @@ Unknown `/` input falls through to normal message handling.
|
|
|
124
126
|
WeChat `sendMessage` is block-send, while LLMs stream. The current strategy is "buffer the turn's reply, flush as one message":
|
|
125
127
|
- Only `stream` events flagged `final` (the wrap-up text of a turn — see [session.md](session.md#message-queue-and-drain)) accumulate in a single buffer; the filler the model emits before a tool call is dropped (it stays in the web UI)
|
|
126
128
|
- `complete` → flush the whole buffer as one (or more) messages. The responder flushes on **any** `complete` and does not read its `batchBoundary` flag — so when the root drains a multi-message queue across several merged turns (see [session.md](session.md#message-queue-and-drain)), each round's batch-boundary `complete` ships that round as its own message instead of buffering every round into one blob that lands only at the terminal `complete` (the "8 reports in one lump" bug)
|
|
127
|
-
- Hard cap `WECHAT_TEXT_LIMIT = 3500` **chars** (JS string length, not bytes — the ilink gateway rejects a `sendmessage` body over 16 KB with `ret=-2 "prepare failed"`; 3500 chars is ≤ ~10.5 KB of CJK plus the JSON envelope).
|
|
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
|
|
129
|
-
- `error` → immediate flush + send a
|
|
129
|
+
- Hard cap `WECHAT_TEXT_LIMIT = 3500` **chars** (JS string length, not bytes — the ilink gateway rejects a `sendmessage` body over 16 KB with `ret=-2 "prepare failed"`; 3500 chars is ≤ ~10.5 KB of CJK plus the JSON envelope). WeChat is one of the two channels that set `splitMidStream` (with Telegram): when the buffer exceeds the limit mid-stream, the shared `splitText` (`channels/shared/chunk.ts`) cuts at the nearest paragraph break (or hard-cuts) and dispatches; the under-limit remainder keeps accumulating. Exported so the cron dispatcher obeys the same limit (see [Proactive sending](#proactive-sending-cron))
|
|
130
|
+
- Every chunk goes through one serialized send chain per responder (`sendTail` in the shared `ChunkedResponder`, `channels/shared/responder.ts`, 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
|
|
131
|
+
- `error` → immediate flush + send a `❌ …` message; `system` → immediate flush + send a `ℹ️ …` message
|
|
130
132
|
- Tool calls / tool results / thinking are dropped (detail lives in the web UI)
|
|
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
|
|
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
|
|
133
|
+
- 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` (`assertMediaPathAllowed`), 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
|
|
134
|
+
- 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 (via the `onSendFailed` override); 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
|
|
133
135
|
|
|
134
136
|
**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.
|
|
135
137
|
|
|
@@ -141,7 +143,7 @@ Plain text + images (base64 attached to the agent message), voice, video, files.
|
|
|
141
143
|
|
|
142
144
|
Supported: text, images, voice, video, files; slash commands listed above; per-session access level.
|
|
143
145
|
|
|
144
|
-
Not supported: group chats, typing indicators.
|
|
146
|
+
Not supported: group chats — a platform limit, not a halo filter: the iLink bot is a one-to-one contact and `getupdates` never carries group messages, so the handler has no group path at all; typing indicators.
|
|
145
147
|
|
|
146
148
|
## References
|
|
147
149
|
|
|
@@ -33,7 +33,7 @@ One 智能机器人 (= one botId + secret pair) is bound to one workspace. A wor
|
|
|
33
33
|
|
|
34
34
|
### Access level
|
|
35
35
|
|
|
36
|
-
Each account carries `accessLevel: 'full' | 'workspace' | 'readonly'
|
|
36
|
+
Each account carries `accessLevel: 'full' | 'workspace' | 'readonly'` (default `readonly`; the route validates against `CHAT_ACCESS_LEVELS`, so `observer` — web-only — is rejected):
|
|
37
37
|
- `full` — no restrictions (no sandbox)
|
|
38
38
|
- `workspace` — tool execution runs inside a bwrap sandbox with the workspace mounted read-write
|
|
39
39
|
- `readonly` — tool execution runs inside a bwrap sandbox with the workspace mounted read-only; readonly sessions only receive read-only tools
|
|
@@ -46,7 +46,7 @@ WeCom bot accounts are stored in the unified channel DB: `~/.halo/secrets/channe
|
|
|
46
46
|
|
|
47
47
|
WeCom-specific config JSON fields: `botId`, `secret`, `lastActiveChatId`.
|
|
48
48
|
|
|
49
|
-
- `lastActiveChatId` is the raw `userid` (single) or `chatid` (group) of the most recent inbound message, written by `rememberLastActiveChat()` in `channels/shared/accounts.ts` (idempotent). Not consumed by cron dispatch (the dispatcher requires an explicit `chatId`).
|
|
49
|
+
- `lastActiveChatId` is the raw `userid` (single) or `chatid` (group) of the most recent inbound message, written by `rememberLastActiveChat()` in `channels/shared/accounts.ts` (idempotent). Not consumed by cron dispatch (the dispatcher requires an explicit `chatId`); read once at `startAccount` to restore the reply route (see [Reply route after a restart](#reply-route-after-a-restart)).
|
|
50
50
|
- `secret` is never returned by `GET /api/wecom/accounts` and is not patchable — a fresh POST with the same `botId` rotates it.
|
|
51
51
|
|
|
52
52
|
Inbound media lands under `<workspace>/.halo/assets/wecom/inbound/<accountId>/<date>/`.
|
|
@@ -67,7 +67,7 @@ The wecom cron-dispatcher (`channels/wecom/cron-dispatcher.ts`) requires an expl
|
|
|
67
67
|
|
|
68
68
|
Because the push rides the handler's socket, the dispatcher borrows it through the exported `liveClients: Map<accountId, WSClient>` (set on `authenticated`, cleared on stop / kick / auth exhaustion). No live client → `{ ok: false, error: 'wecom long-connect not active' }` rather than queueing. WeCom additionally refuses pushes to a user / group that has never messaged the bot, and rate-limits each conversation to 30 msgs/min · 1000/hour (replies and pushes combined).
|
|
69
69
|
|
|
70
|
-
**`MEDIA:` is not implemented for cron** — the dispatcher declares no `supportsMedia`, so `dispatchToTargets` hands it the original text with `MEDIA:` lines intact (the path degrades to visible text, same caveat as telegram / feishu in [cron.md](cron.md#dispatch-model)). The realtime uploader (`sendWecomMedia` in `handler.ts`)
|
|
70
|
+
**`MEDIA:` is not implemented for cron** — the dispatcher declares no `supportsMedia`, so `dispatchToTargets` hands it the original text with `MEDIA:` lines intact (the path degrades to visible text, same caveat as telegram / feishu in [cron.md](cron.md#dispatch-model)). The realtime uploader (`sendWecomMedia` in `handler.ts`) already falls back to `sendMediaMessage(chatid, …)` for a route without a `req_id` (restored at startup); wiring cron up means calling it with `{ reqId: '', chatId }` and flipping `supportsMedia: true`.
|
|
71
71
|
|
|
72
72
|
## Modules
|
|
73
73
|
|
|
@@ -97,6 +97,7 @@ Session-prefix kind `'wecom'` is registered in `channels/shared/session-prefix.t
|
|
|
97
97
|
2. Server stores the account, calls `stopAccount` (idempotent) then `startAccount` — the stop must fully `disconnect()` before the new socket subscribes, or the new connection would kick its own predecessor
|
|
98
98
|
3. `startAccount` builds `new WSClient({ botId, secret, maxReconnectAttempts: -1, maxAuthFailureAttempts: 3, logger })` and calls `connect()` (synchronous; auth happens on `authenticated`). The SDK handles heartbeat (30 s ping), reconnect with exponential back-off (infinite on network drops), and the auth retry budget
|
|
99
99
|
4. `authenticated` → `liveClients.set(accountId, client)`; `event.disconnected_event` (kicked) or `error` with `WS_AUTH_FAILURE_EXHAUSTED` → client dropped, `liveClients.delete`
|
|
100
|
+
Before `connect()`, `startAccount` restores the reply route from `lastActiveChatId` (see [Reply route after a restart](#reply-route-after-a-restart))
|
|
100
101
|
5. `stopAccount` = `bridge.closeAll()` (drains queued chunks, then releases reply routes) → `wsClient.disconnect()` → `liveClients.delete` → state removed
|
|
101
102
|
6. On graceful shutdown the descriptor's `shutdown` calls `stopAll()`
|
|
102
103
|
|
|
@@ -110,11 +111,23 @@ All frames are JSON `{ cmd, headers: { req_id }, body }`; responses echo `header
|
|
|
110
111
|
| `ping` / pong | client → server | SDK heartbeat |
|
|
111
112
|
| `aibot_msg_callback` | server → client | inbound message → `'message'` event |
|
|
112
113
|
| `aibot_event_callback` | server → client | events; only `disconnected_event` is handled (kicked). `enter_chat` / `template_card_event` / `feedback_event` are ignored |
|
|
113
|
-
| `aibot_respond_msg` | client → server | `replyStream(frame, streamId, content, finish)` —
|
|
114
|
-
| `aibot_send_msg` | client → server | `sendMessage(chatid, { msgtype: 'markdown' })` — cron
|
|
114
|
+
| `aibot_respond_msg` | client → server | `replyStream(frame, streamId, content, finish)` — replies with a live `req_id` |
|
|
115
|
+
| `aibot_send_msg` | client → server | `sendMessage(chatid, { msgtype: 'markdown' })` / `sendMediaMessage(chatid, …)` — cron, and replies on a route restored at startup |
|
|
115
116
|
| `aibot_upload_media_init` / `_chunk` / `_finish` | client → server | `uploadMedia(buffer, { type, filename })` — chunked (≤ 512 KB × ≤ 100), returns `media_id` (valid 3 days) |
|
|
116
117
|
|
|
117
|
-
**Reply model.** A stream reply is keyed to the callback's `req_id` and carries its own `stream.id`; the same `stream.id` can be re-sent to update the bubble until `finish: true`. Halo does **not** stream: every flushed chunk / hint / command reply is sent as a *new* `stream.id` with `finish: true` in one go — one finished bubble each. This keeps the responder identical to the other channels and sidesteps the 10-minute cap on an open stream (measured from its first frame). Replies must reference the inbound `req_id`, so the reply route stored per session is `{ reqId,
|
|
118
|
+
**Reply model.** A stream reply is keyed to the callback's `req_id` and carries its own `stream.id`; the same `stream.id` can be re-sent to update the bubble until `finish: true`. Halo does **not** stream: every flushed chunk / hint / command reply is sent as a *new* `stream.id` with `finish: true` in one go — one finished bubble each. This keeps the responder identical to the other channels and sidesteps the 10-minute cap on an open stream (measured from its first frame). Replies must reference the inbound `req_id`, so the reply route stored per session is `{ reqId, chatId }`, refreshed on every inbound message (the route is read lazily at send time, as in slack / feishu). An empty `reqId` (restored route, below) switches `replyText` / `sendWecomMedia` to the proactive push.
|
|
119
|
+
|
|
120
|
+
### Reply route after a restart
|
|
121
|
+
|
|
122
|
+
Like the other channels, `startAccount` calls `restoreReplyRoute` → `restoreChannelRoute` (`channels/shared/inbound.ts`) before connecting, so a session that resumes on its own after a restart (run-ledger nudge, queued turn, admin-typed message) still reaches WeCom before the user writes again. It reads `lastActiveChatId`, takes the latest existing root session under `wecom_<normalizeWecomId(chatId)>_` (goal-routed, never creates one) and wires route `{ reqId: '', chatId }` + listener. Both single chats and groups restore: the cached raw id maps 1:1 onto the session key, unlike telegram (a group id doesn't name the member) or feishu (a group thread reply needs the inbound message id).
|
|
123
|
+
|
|
124
|
+
What is **not** restored, and why:
|
|
125
|
+
|
|
126
|
+
- **Passive replies.** The inbound `req_id` lived only in the old process's memory (and is only valid ~10 minutes anyway), so the restored route has none. Text goes out as `aibot_send_msg` markdown, media as `uploadMedia` + `sendMediaMessage`. That is a push, not a stream reply: the bubble isn't attached to the user's message, and the push channel's own rules apply — it is refused for a user / group that has never messaged the bot, and counts against the 30 msgs/min · 1000/hour per-conversation limit.
|
|
127
|
+
- **Only the last active chat.** One `lastActiveChatId` per account → at most one session restored; other users' sessions wait for their next message (same as telegram / slack / feishu).
|
|
128
|
+
- **A connected socket.** The route is wired before `connect()`. A send that fires before the socket is open is rejected by the SDK (`WebSocket not connected`) and logged as `[WeCom] sendText failed`; after a kick / auth exhaustion there is no `wsClient` and the send is skipped — same as for any route. In practice the resumed turn's model call outlasts the ~1 s auth handshake.
|
|
129
|
+
|
|
130
|
+
The next inbound message from that chat replaces the restored route with a fresh `req_id`, and replies go back to passive streaming.
|
|
118
131
|
|
|
119
132
|
## Message handling flow
|
|
120
133
|
|
|
@@ -147,18 +160,19 @@ Stream `content` is capped at 20480 **bytes**. The responder splits at 5000 **ch
|
|
|
147
160
|
- `MEDIA:<path>` markers intercepted and sent via native media upload
|
|
148
161
|
- Sub-agent events dropped (visible in web UI only)
|
|
149
162
|
|
|
150
|
-
Chunk sends are serialized per responder (`sendTail` promise chain, audit A-L3) and `close()` returns the drain promise so `InboundBridge` keeps the reply route alive until the last chunk is out —
|
|
163
|
+
Chunk sends are serialized per responder (`sendTail` promise chain, audit A-L3) and `close()` returns the drain promise so `InboundBridge` keeps the reply route alive until the last chunk is out — both come from the shared `ChunkedResponder` (`channels/shared/responder.ts`) that the feishu adapter also extends, see [feishu.md](feishu.md#event-coalescing-feishuresponder) for the history.
|
|
151
164
|
|
|
152
165
|
## Media support
|
|
153
166
|
|
|
154
167
|
**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
168
|
|
|
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):
|
|
169
|
+
**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 — `sendMediaOrReport` in `channels/shared/media.ts`):
|
|
157
170
|
- `.png` / `.jpg` / `.jpeg` / `.gif` → `uploadMedia(type: 'image')` → `replyMedia('image')`
|
|
158
171
|
- `.mp4` → `uploadMedia(type: 'video')` → `replyMedia('video')`
|
|
159
172
|
- everything else (`.webp` / `.bmp` / `.mov` / documents / audio) → `uploadMedia(type: 'file')` → `replyMedia('file')` — WeCom's `image` accepts png/jpg/gif only and `video` mp4 only
|
|
160
173
|
- > 20 MB → refused; the user sees `t('handler.upload_failed')` (`⚠️ 文件上传失败:name — error`)
|
|
161
174
|
- Voice is never sent: WeCom expects AMR, which the agent doesn't produce
|
|
175
|
+
- On a route restored at startup (no `req_id` yet) every `replyMedia(...)` above becomes `sendMediaMessage(chatId, type, mediaId)` — the proactive push
|
|
162
176
|
|
|
163
177
|
## Similarities vs Feishu and Telegram
|
|
164
178
|
|
|
@@ -212,6 +226,6 @@ Not supported: streaming partial replies into WeCom (each reply is one finished
|
|
|
212
226
|
- Event dispatch: `packages/server/src/channels/wecom/handler.ts:handleInbound()`
|
|
213
227
|
- Media upload: `packages/server/src/channels/wecom/handler.ts:sendWecomMedia()`
|
|
214
228
|
- Cron dispatch: `packages/server/src/channels/wecom/cron-dispatcher.ts:dispatch()`
|
|
215
|
-
- Event coalescing: `packages/server/src/channels/wecom/event-adapter.ts:WecomResponder`
|
|
229
|
+
- Event coalescing: `packages/server/src/channels/wecom/event-adapter.ts:WecomResponder` (extends `channels/shared/responder.ts:ChunkedResponder`)
|
|
216
230
|
- REST routes: `packages/server/src/routes/wecom.ts`
|
|
217
231
|
- Tests: `packages/server/test/wecom-inbound.test.ts` (pure helpers), `wecom-responder.test.ts` (chunking / ordering / markdown pass-through)
|
|
@@ -48,7 +48,7 @@ Liveness probes bound how long a zombie lives, but a chat sent *into* the zombie
|
|
|
48
48
|
|
|
49
49
|
- **Client** ([ws-client.ts](../../../packages/admin/src/shared/ws-client.ts)): every chat carries a client-generated `clientMsgId` and enters a pending-ack table (capacity 100 — past that the oldest entry is failed visibly) before transmission. Transmission is gated on freshness: if inbound has been silent > 30 s, the socket is torn down first and the chat rides the reconnect instead of being trusted to a suspect link. **5 s without `chat:ack`** → force-close, and the `onopen` flush retransmits every unacked entry in insertion order. After **3 transmit attempts** the client stops tearing the link down on that chat's behalf (rapid network flaps would otherwise turn one chat into endless connection churn) but the entry stays pending and still rides every flush. A **2 min wall-clock deadline** per entry is the final arbiter: every chat terminates either in an ack or in a visible `_chat_send_failed` (red "send failed" badge on the bubble + its empty placeholder converged) — no infinite lurking.
|
|
50
50
|
- **Server** ([handler.ts](../../../packages/server/src/ws/handler.ts)): ack semantics = *the message is in the session log*. Each `handleChat` branch calls `rememberChat` **synchronously right after `appendUserMessage`** (no `await` in between — an await there opens a double-append window against a racing resend), then replies `{type:'chat:ack', clientMsgId}` once enqueueing is done. Resends are deduped against a handler-scoped FIFO of the last 500 acked ids (handler-scoped because a resend arrives on a *new* connection): a duplicate is re-acked without re-appending. At-least-once resend + server dedup ≈ exactly-once in the session log.
|
|
51
|
-
- **Indicator**: the connection
|
|
51
|
+
- **Indicator**: the connection indicator (the network segment of the activity bar's quick-toggles entry) is tri-state (`fresh` / `stale` / `down`, [use-websocket.ts](../../../packages/admin/src/shared/use-websocket.ts)) driven by inbound-traffic age rather than last-known socket state — `stale` (amber) at > 45 s of silence (3× probe interval), `down` (red) on close. A zombie link can no longer sit green.
|
|
52
52
|
- **Known boundary (accepted)**: the dedup FIFO is in-memory. If the server restarts inside the narrow window between "ack sent but lost in flight" and the client's resend, the new process re-appends the message and the agent runs it twice. The overlap is seconds-rare, and persisting the table would put a synchronous disk write on the chat hot path — not worth it unless observed in practice.
|
|
53
53
|
|
|
54
54
|
## Client → Server
|
|
@@ -86,7 +86,7 @@ Optional `chat` fields:
|
|
|
86
86
|
|
|
87
87
|
## Server → Client
|
|
88
88
|
|
|
89
|
-
Source: [event-processor.ts
|
|
89
|
+
Source: [event-processor.ts](../../../packages/server/src/ws/event-processor.ts) `sendWsNotification` switch.
|
|
90
90
|
|
|
91
91
|
### Agent event → WS message mapping
|
|
92
92
|
|
|
@@ -102,7 +102,7 @@ Source: [event-processor.ts:53-129](../../../packages/server/src/ws/event-proces
|
|
|
102
102
|
| `tool_result` | `agent:tool_result` | result, toolUseId, agentName, taskId, durationMs, sessionId |
|
|
103
103
|
| `followup_start` / `queued_message` | `chat:followup` | agentName, sessionId |
|
|
104
104
|
| `usage` (no taskId) | `chat:usage` | contextTokens, outputTokens, turnId, modelId, usage, sessionId |
|
|
105
|
-
| `complete` | `chat:complete` | sessionId |
|
|
105
|
+
| `complete` | `chat:complete` | sessionId, batchBoundary (only between drained queued turns; the admin closes bubbles but doesn't ring the finish bell) |
|
|
106
106
|
| `context` | `agent:context` | agentName, systemPrompt, taskId, sessionId |
|
|
107
107
|
| `system` | `chat:system` | text, taskId, agentName, sessionId |
|
|
108
108
|
| `error` | `error` | error, agentName, taskId, sessionId |
|
|
@@ -118,7 +118,7 @@ Every provider streams (Bedrock, the Anthropic-Messages providers anthropic / mi
|
|
|
118
118
|
|
|
119
119
|
`chat:system` producers (`session-manager.ts`'s `stop` event handling): a `max_tokens` stop emits `⚠️ [<agent>] Response truncated: output token limit reached.`; a `refusal` stop (Anthropic `stop_reason: "refusal"`, HTTP 200 — the model declined, not an error) emits `⚠️ [<agent>] Model declined to respond (<category>): <explanation> — …` suggesting `/new` (see [session.md](session.md#resilient-execution-loop)).
|
|
120
120
|
|
|
121
|
-
Server-internal flags on `AgentSessionEvent` that are **not** carried into the WS frame: `stream.final` (marks the turn's wrap-up text vs. pre-tool filler — consumed by channel responders and the cli, see [session.md](session.md#message-queue-and-drain)), `stream.streamed` / `thinking.streamed` (the text already went out as deltas — decides whether the whole event is forwarded at all)
|
|
121
|
+
Server-internal flags on `AgentSessionEvent` that are **not** carried into the WS frame: `stream.final` (marks the turn's wrap-up text vs. pre-tool filler — consumed by channel responders and the cli, see [session.md](session.md#message-queue-and-drain)), `stream.streamed` / `thinking.streamed` (the text already went out as deltas — decides whether the whole event is forwarded at all). The admin renders every streamed block, so none is needed on the wire.
|
|
122
122
|
|
|
123
123
|
### Other Server → Client messages
|
|
124
124
|
|
|
@@ -231,11 +231,11 @@ Client reconnects and sends `subscribe`:
|
|
|
231
231
|
|
|
232
232
|
### Double-subscribe guard
|
|
233
233
|
|
|
234
|
-
|
|
234
|
+
The reattach path reads the cached UIState (`getCachedUIState`), never disk. A plain subscribe goes through `getSessionView` → `prepareForView`, which keeps the cached state while the session is live in this process (self-driven, or active work in its tree) and re-seeds from disk otherwise — so a repeat subscribe can't overwrite in-flight state with stale file data.
|
|
235
235
|
|
|
236
236
|
### Client-side reconnect reconciliation
|
|
237
237
|
|
|
238
|
-
The chat stream reconciles itself (above), but several admin panels keep state in sync purely from incremental `file:changed` / bus deltas — events emitted while the socket was down are lost forever, leaving them stale until an unrelated event arrives, or indefinitely when none does. Every such subscriber pairs its delta subscription with `onWsReconnect(wsClient, <its existing refetch>)` ([ws-reconnect.ts](../../../packages/admin/src/shared/ws-reconnect.ts)) — a reconnect re-reads from the server instead of trusting the gap. Current consumers: file tree (`use-file-tree.ts` → `loadFileTree`, silent replace), editor open tabs (`editor-panel.tsx` → `refreshActiveTab`), git decorations, Source Control panel + history graph, skills sidebar, agent-management list, agent session-chat panel,
|
|
238
|
+
The chat stream reconciles itself (above), but several admin panels keep state in sync purely from incremental `file:changed` / bus deltas — events emitted while the socket was down are lost forever, leaving them stale until an unrelated event arrives, or indefinitely when none does. Every such subscriber pairs its delta subscription with `onWsReconnect(wsClient, <its existing refetch>)` ([ws-reconnect.ts](../../../packages/admin/src/shared/ws-reconnect.ts)) — a reconnect re-reads from the server instead of trusting the gap. Current consumers: file tree (`use-file-tree.ts` → `loadFileTree`, silent replace), editor open tabs (`editor-panel.tsx` → `refreshActiveTab`), git decorations, Source Control panel + history graph, skills sidebar, agent-management list, agent session-chat panel, the session-list bus and the preview-extension list (`state-handlers.ts`).
|
|
239
239
|
|
|
240
240
|
`everConnected` seeds from `client.connected`, which makes "reconnect" mean the same thing for both mount timings: a panel mounting on an already-open socket treats the next `_connected` as a reconnect, while a page-load mount does **not** fire on its first `_connected` (the subscriber's own mount fetch already covered it — firing would be a pure double-pull).
|
|
241
241
|
|
|
@@ -34,7 +34,7 @@ Claude Code on a developer's laptop wants to talk to a halo agent running in an
|
|
|
34
34
|
--port 9527 \
|
|
35
35
|
--token <web-token-from-step-1> \
|
|
36
36
|
--workspace /abs/path/on/server \
|
|
37
|
-
--agent-id default # optional;
|
|
37
|
+
--agent-id default # optional; omitted = the workspace's entry agent
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
3. The adapter writes JSON-RPC frames to stdout (1 message per line) and reads stdin the same way. Stderr is reserved for human-readable diagnostics — do not parse.
|
|
@@ -80,7 +80,7 @@ To remove a binding: `/acp remove` (deletes the skill directory and points out t
|
|
|
80
80
|
| `--scheme` | no | URL scheme — `http` or `https` (default `http`). Use `https` when the server sits behind a TLS reverse proxy. Invalid values are rejected at the adapter boundary. |
|
|
81
81
|
| `--token` | yes | Web-channel token from admin UI. `full` access required for multi-workspace use. |
|
|
82
82
|
| `--workspace` | yes | Absolute server-side path for the workspace this adapter drives. |
|
|
83
|
-
| `--agent-id` | no | Halo agent profile to use when ACP `session/new` creates a new halo session. Default: `default
|
|
83
|
+
| `--agent-id` | no | Halo agent profile to use when ACP `session/new` creates a new halo session. Must be a non-internal, non-disabled agent (else `session/new` fails with `agent not available`). Default: the workspace's entry agent — the highest-`priority` non-disabled, non-internal agent, i.e. `default` out of the box. Alias: `--agent` (matches the main CLI's `--agent`). |
|
|
84
84
|
| `--header` | no | Extra HTTP header on every upstream request, `"Name: value"` (like `curl -H`). Repeatable. For auth that sits **in front of** the halo server — see "Upstream auth" below. |
|
|
85
85
|
|
|
86
86
|
One adapter process binds to one workspace. To drive multiple workspaces concurrently from the same token, run multiple adapter processes — see "Multi-workspace" below.
|
|
@@ -89,7 +89,7 @@ One adapter process binds to one workspace. To drive multiple workspaces concurr
|
|
|
89
89
|
|
|
90
90
|
`--token` authenticates the adapter to *halo itself* (it becomes the `x-token` header on every web-channel call). It does **not** cover a proxy sitting in front of the server — an SSO/session-cookie gateway, Cloudflare Access, ALB OIDC, nginx basic-auth. Those reject the request before halo ever sees the token.
|
|
91
91
|
|
|
92
|
-
`--header` is the generic escape hatch: it forwards arbitrary headers on every request the adapter makes (`/api/web/chat`, `/history`, `/stop`), exactly like `curl -H`. The adapter deliberately knows nothing about any specific gateway — you supply whatever that layer wants:
|
|
92
|
+
`--header` is the generic escape hatch: it forwards arbitrary headers on every request the adapter makes (`/api/web/sessions`, `/chat`, `/history`, `/stop`), exactly like `curl -H`. The adapter deliberately knows nothing about any specific gateway — you supply whatever that layer wants:
|
|
93
93
|
|
|
94
94
|
```sh
|
|
95
95
|
# session-cookie gateway
|
|
@@ -137,8 +137,8 @@ This keeps the adapter stateless on disk — losing the in-memory map on restart
|
|
|
137
137
|
| `session` | (latched internally) | First-frame echo of the resolved sessionId. Adapter records it; not surfaced. |
|
|
138
138
|
| `stream` (assistant text) | `agent_message_chunk` | Forwarded as `content: { type: 'text', text }`. |
|
|
139
139
|
| `thinking` | `agent_thought_chunk` | Same shape as message chunk. |
|
|
140
|
-
| `tool_call` | `tool_call` (status: in_progress) | Adapter mints a stable `toolCallId`. `kind: 'other'` because halo doesn't categorize tools. |
|
|
141
|
-
| `tool_result` | `tool_call_update` (status: completed) | Pairs by *order* with the most recent `tool_call`
|
|
140
|
+
| `tool_call` | `tool_call` (status: in_progress) | Adapter mints a stable `toolCallId`; the halo `toolInput` goes out as `rawInput`. `kind: 'other'` because halo doesn't categorize tools. |
|
|
141
|
+
| `tool_result` | `tool_call_update` (status: completed) | Pairs by *order* with the most recent `tool_call` (the frame's `toolName`, when present, must match it). A result with no matching call is sent as a self-contained `tool_call` + `tool_call_update`. The web channel truncates `result` to 500 chars. |
|
|
142
142
|
| `file` | `agent_message_chunk: [file: …]` | The file lives on the server; without reverse fs we can only point at it textually. |
|
|
143
143
|
| `error` | `agent_message_chunk: [error] …` then end | Ends the prompt response with `stopReason: 'end_turn'` (we treat agent errors as a normal end-of-turn for protocol purposes). |
|
|
144
144
|
| `queued` | `agent_message_chunk: [queued — session busy]` | Halo queues messages when the session is busy. Adapter ends the response. |
|
|
@@ -150,7 +150,7 @@ This keeps the adapter stateless on disk — losing the in-memory map on restart
|
|
|
150
150
|
|
|
151
151
|
A single web-channel token in halo is bound to one workspace at the database level. The adapter works around this by using the per-request `workspace` + `sessionId` overrides on `/api/web/*`:
|
|
152
152
|
|
|
153
|
-
- `/api/web/chat`, `/api/web/stop`, `/api/web/history`, `/api/web/subscribe` accept `workspace=<path>` and `sessionId=<id>` (query params, headers `x-workspace` / `x-session-id`, or POST body fields).
|
|
153
|
+
- `/api/web/chat`, `/api/web/stop`, `/api/web/history`, `/api/web/subscribe` accept `workspace=<path>` and `sessionId=<id>` (query params, headers `x-workspace` / `x-session-id`, or POST body fields); `/api/web/sessions` accepts `workspace` and `agentId` the same way.
|
|
154
154
|
- Server gates the workspace override on `accessLevel === 'full'` — readonly / workspace tokens cannot escape their account-bound workspace.
|
|
155
155
|
- The adapter sends both fields on every request, so concurrent adapters on the same token but different `--workspace` flags don't step on each other.
|
|
156
156
|
|
|
@@ -172,7 +172,7 @@ For now: if the user wants the agent to see a Mac-side file, they paste it into
|
|
|
172
172
|
Code is in `packages/acp-adapter/`:
|
|
173
173
|
|
|
174
174
|
- `src/jsonrpc.ts` — minimal newline-delimited JSON-RPC 2.0 peer over stdio. No LSP-style Content-Length framing — ACP uses one JSON object per line.
|
|
175
|
-
- `src/halo-client.ts` — wraps `POST /api/web/chat` (SSE), `POST /api/web/stop`. Parses `data: <json>\n\n` frames into JS objects.
|
|
175
|
+
- `src/halo-client.ts` — wraps `POST /api/web/sessions`, `POST /api/web/chat` (SSE), `POST /api/web/stop` and `GET /api/web/history`. Parses `data: <json>\n\n` frames into JS objects.
|
|
176
176
|
- `src/adapter.ts` — registers the ACP method handlers, owns the per-session state (`Map<sessionId, { workspace, lastToolCall, promptAbort }>` — `sessionId` is shared with halo), translates SSE events to `session/update` notifications.
|
|
177
177
|
- `src/index.ts` — CLI argv parsing, wires stdin/stdout to a `JsonRpcConnection`, instantiates the adapter.
|
|
178
178
|
|
|
@@ -180,7 +180,7 @@ CLI integration is in `@turmind/halo-cli`'s `index.ts` `cmd === 'acp'` branch
|
|
|
180
180
|
|
|
181
181
|
## Testing
|
|
182
182
|
|
|
183
|
-
|
|
183
|
+
`packages/acp-adapter/test/` holds vitest unit tests for the JSON-RPC framing and SSE parsing (`pnpm --filter @turmind/halo-acp-adapter test`); the adapter ↔ server flow has no automated suite and is verified by manual smoke. The cases below cover the protocol surface and the realistic end-to-end shape (Claude Code → ACP adapter → halo server → remote agent). When you change adapter / web-channel / settings code, walk this list.
|
|
184
184
|
|
|
185
185
|
### Setup
|
|
186
186
|
|
|
@@ -28,7 +28,7 @@ What you write yourself:
|
|
|
28
28
|
|
|
29
29
|
## Reference implementation
|
|
30
30
|
|
|
31
|
-
Whole directory: [packages/server/src/channels/wechat/](../../../packages/server/src/channels/wechat/) (~
|
|
31
|
+
Whole directory: [packages/server/src/channels/wechat/](../../../packages/server/src/channels/wechat/) (~1800 lines across 10 files).
|
|
32
32
|
|
|
33
33
|
The pattern that works:
|
|
34
34
|
|
|
@@ -88,6 +88,7 @@ All channel accounts live in a single unified table: `~/.halo/secrets/channels/c
|
|
|
88
88
|
2. Create `accounts.ts` as a thin adapter over `channels/shared/accounts.ts`:
|
|
89
89
|
- Map `ChannelAccount.config` JSON → your typed interface
|
|
90
90
|
- Pass `channelType = 'slack'` to all shared DAL calls
|
|
91
|
+
- Write through `insertChannelAccount(db, 'slack', data, config)` / `updateChannelAccount(db, id, patch, configKeys)` — the shared helpers own the row columns + config-JSON merge; your file only lists its config keys
|
|
91
92
|
|
|
92
93
|
Reference adapters: [channels/telegram/accounts.ts](../../../packages/server/src/channels/telegram/accounts.ts), [channels/web/accounts.ts](../../../packages/server/src/channels/web/accounts.ts).
|
|
93
94
|
|
|
@@ -101,11 +102,11 @@ Inbound: Slack pushes events via **HTTPS webhooks** (Events API) or **WebSocket*
|
|
|
101
102
|
|
|
102
103
|
### 4. Write the main handler
|
|
103
104
|
|
|
104
|
-
`handler.ts`: exports `startSlackChannel(deps: { registry, db })` returning `{ startAccount, stopAccount, stopAll }`. WeChat shape
|
|
105
|
+
`handler.ts`: exports `startSlackChannel(deps: { registry, db })` returning `{ startAccount, stopAccount, stopAll }`. WeChat shape: `startWechatChannel` in [packages/server/src/channels/wechat/handler.ts](../../../packages/server/src/channels/wechat/handler.ts).
|
|
105
106
|
|
|
106
107
|
For webhook-style channels (Slack Events API), you don't need a `runAccountLoop` — instead expose an HTTP route that validates the signature, deserializes the event, and calls `handleInbound`. For Socket Mode, you **do** have a loop (the WebSocket reconnect loop).
|
|
107
108
|
|
|
108
|
-
Inside `handleInbound`, **don't hand-roll the session/listener plumbing** — the get-or-create, access folding, busy hint, responder registration and agent-input assembly all live in [channels/shared/inbound.ts](../../../packages/server/src/channels/shared/inbound.ts) (`InboundBridge` + `deliverInbound` + `dispatchChannelCommand`). All
|
|
109
|
+
Inside `handleInbound`, **don't hand-roll the session/listener plumbing** — the get-or-create, access folding, busy hint, responder registration and agent-input assembly all live in [channels/shared/inbound.ts](../../../packages/server/src/channels/shared/inbound.ts) (`InboundBridge` + `deliverInbound` + `dispatchChannelCommand`). All five IM channels go through it; the hand-written copies it replaced are how audit findings A-M2 (responder locked onto the first user in a chat) and A-M5 (skill-command turns ran with no listener attached) happened. Your handler parses the platform message and supplies a **reply route**:
|
|
109
110
|
|
|
110
111
|
```ts
|
|
111
112
|
// One bridge per running account, owned by the account state so
|
|
@@ -146,14 +147,14 @@ async function handleInbound({ registry, db, account, event, bridge, activeOverr
|
|
|
146
147
|
|
|
147
148
|
### 5. Write the event adapter
|
|
148
149
|
|
|
149
|
-
`event-adapter.ts`: buffers streamed text and sends whole messages. WeChat's flushes on either a 3500-char hard ceiling (`WECHAT_TEXT_LIMIT`, the ilink gateway rejects >16 KB) or a `complete` event; see [packages/server/src/channels/wechat/event-adapter.ts](../../../packages/server/src/channels/wechat/event-adapter.ts). Use the shared `splitText(text, limit)` from `channels/shared/chunk.ts` for the cut (paragraph boundary, then hard-cut) —
|
|
150
|
+
`event-adapter.ts`: buffers streamed text and sends whole messages. WeChat's flushes on either a 3500-char hard ceiling (`WECHAT_TEXT_LIMIT`, the ilink gateway rejects >16 KB) or a `complete` event; see [packages/server/src/channels/wechat/event-adapter.ts](../../../packages/server/src/channels/wechat/event-adapter.ts). Use the shared `splitText(text, limit)` from `channels/shared/chunk.ts` for the cut (paragraph boundary, then hard-cut) — every existing responder does; don't write a private splitter. Slack has a 40k char limit but users dislike huge messages, so it splits at 35k. Block-oriented channels extend `ChunkedResponder` from `channels/shared/responder.ts` (all five existing channels do), passing `{ limit, logTag, format?, splitMidStream? }`: it buffers `final` text, splits with `splitText` (mid-stream too with `splitMidStream`, as telegram / wechat do), serializes sends through one `sendTail` promise chain and returns it from `close()` so the bridge keeps the route alive until the tail has gone out. Override `onSendFailed` only for a real channel quirk (wechat's `onSendError`).
|
|
150
151
|
|
|
151
152
|
Its send primitives take **no destination argument** — they read `bridge.getRoute(sessionId)` on each send (see step 4). A responder that captured a chat/user id at construction time is the A-M2 bug.
|
|
152
153
|
|
|
153
154
|
**What to forward, what to drop**:
|
|
154
155
|
- Drop `tool_call` / `tool_result` / `thinking` events — they're chatter the user doesn't want in IM
|
|
155
156
|
- Drop events where `event.taskId` is set (those are sub-agent events; the root agent's text is enough)
|
|
156
|
-
- Forward `stream` text **only when `event.final` is set** — that's the turn's closing reply; the filler the model emits before a tool call ("let me check…") is chatter in IM too. `error` goes out immediately with a `
|
|
157
|
+
- Forward `stream` text **only when `event.final` is set** — that's the turn's closing reply; the filler the model emits before a tool call ("let me check…") is chatter in IM too. `error` goes out immediately with a `❌ ` prefix (`system` with `ℹ️ `); `complete` flushes the remaining buffer
|
|
157
158
|
- Forward `system` if the platform can render it (e.g. Slack ephemeral messages)
|
|
158
159
|
|
|
159
160
|
### 6. Surface channel context to skills
|
|
@@ -235,7 +236,7 @@ export function createSlackRoutes(deps: { db, channel }) {
|
|
|
235
236
|
}
|
|
236
237
|
```
|
|
237
238
|
|
|
238
|
-
Pattern: [packages/server/src/routes/wechat.ts](../../../packages/server/src/routes/wechat.ts).
|
|
239
|
+
Pattern: [packages/server/src/routes/wechat.ts](../../../packages/server/src/routes/wechat.ts). Shared CRUD pieces live in [packages/server/src/routes/channel-accounts.ts](../../../packages/server/src/routes/channel-accounts.ts): `accountListFields` (common GET row, no credentials), `accountBodyError` (accessLevel + workspacePath checks) and `accountPatchFromBody` (row part of a PATCH); credentials, required fields, id derivation and restart policy stay in your route file.
|
|
239
240
|
|
|
240
241
|
### 8. Write the server descriptor + register
|
|
241
242
|
|
|
@@ -275,7 +276,7 @@ That's all the server wiring. `index.ts` calls `bootChannels(app, defaultChannel
|
|
|
275
276
|
|
|
276
277
|
To let users manage Slack accounts from the web UI:
|
|
277
278
|
|
|
278
|
-
1. Build the
|
|
279
|
+
1. Build the settings page at `packages/admin/src/features/slack/slack-settings.tsx` on the shared pieces in `packages/admin/src/features/channels/`: `useChannelAccounts` + `ChannelAccountsPage` + `ChannelAccountRow` ([account-list.tsx](../../../packages/admin/src/features/channels/account-list.tsx)) for the list, header and inline edit, and `ChannelAddDialog` ([account-form.tsx](../../../packages/admin/src/features/channels/account-form.tsx)) for the add dialog. The page keeps only what's channel-specific: the account type, the row's name / detail line, the credential inputs, and the `api.slack.*` calls. Mirror [packages/admin/src/features/wecom/wecom-settings.tsx](../../../packages/admin/src/features/wecom/wecom-settings.tsx). The shared parts read i18n keys from the channel's own namespace (`slack.title`, `slack.add`, `slack.addTitle`, `slack.addBtn`, `slack.confirmDelete`, …). The full key list is in the header comments of the two files.
|
|
279
280
|
2. Create `packages/admin/src/features/slack/descriptor.ts`:
|
|
280
281
|
```ts
|
|
281
282
|
import { Hash } from 'lucide-react' // or whatever icon fits
|
|
@@ -345,7 +346,7 @@ Channel-specific commands (e.g. WeChat's `/qr`) go in a fallback switch after `d
|
|
|
345
346
|
|
|
346
347
|
### Compact / busy states
|
|
347
348
|
|
|
348
|
-
Handled for you inside `deliverInbound`: it calls `busyHint(sm, sessionId, lang)` ([channels/shared/busy-hint.ts](../../../packages/server/src/channels/shared/busy-hint.ts)) and sends the result through your `sendHint` callback. The hint is a **hint only** — the message is always delivered, because `sendUserMessage` queues compacting/busy sessions itself. (The
|
|
349
|
+
Handled for you inside `deliverInbound`: it calls `busyHint(sm, sessionId, lang)` ([channels/shared/busy-hint.ts](../../../packages/server/src/channels/shared/busy-hint.ts)) and sends the result through your `sendHint` callback. The hint is a **hint only** — the message is always delivered, because `sendUserMessage` queues compacting/busy sessions itself. (The inlined per-channel copies once carried an extra `return` in the compacting branch, so messages sent mid-compact were answered with a hint and then silently dropped.)
|
|
349
350
|
|
|
350
351
|
### Single-instance lock
|
|
351
352
|
|
|
@@ -361,13 +362,15 @@ At handler startup, `resolveAccountWorkspace(account)` checks the path exists on
|
|
|
361
362
|
|
|
362
363
|
Use the shared helper `saveInboundMedia({ workspacePath, accountId, channel: 'slack', buffer, kind: 'image', mimeType })` — [packages/server/src/channels/shared/media-store.ts](../../../packages/server/src/channels/shared/media-store.ts). It saves under `<ws>/.halo/assets/slack/inbound/<accountId>/<date>/` and returns the path. Append `[图片已保存: /abs/path]` to the agent's input text and the existing UI code will render a thumbnail.
|
|
363
364
|
|
|
365
|
+
Outbound `MEDIA: <path>` markers go through `sendMediaOrReport({ filePath, account, logTag, send, reply })` ([channels/shared/media.ts](../../../packages/server/src/channels/shared/media.ts)): it runs the access-level path check (`assertMediaPathAllowed`), then your `send()` upload, and reports a block or failed upload back to the chat. A responder that already has its own failure channel can call `assertMediaPathAllowed` directly and let the throw ride `onSendError` (WeChat does this).
|
|
366
|
+
|
|
364
367
|
For images going to the LLM, also pass them as base64 in the `images` arg — `sm.sendUserMessage(sid, text, images)`.
|
|
365
368
|
|
|
366
369
|
---
|
|
367
370
|
|
|
368
371
|
## Testing
|
|
369
372
|
|
|
370
|
-
|
|
373
|
+
Shared channel plumbing has automated coverage under `packages/server/test/` (`channel-*.test.ts` for the inbound bridge, responder ordering / `final`-only forwarding, media whitelist, route restore, access-level and workspace validation; plus per-channel files such as `telegram-group-chat.test.ts`, `wecom-inbound.test.ts`, `wecom-responder.test.ts`). The platform round-trip itself is still manual:
|
|
371
374
|
|
|
372
375
|
1. Create a test bot on the platform (development workspace / test server)
|
|
373
376
|
2. Bind it to a throwaway Halo workspace: `POST /api/slack/accounts`
|
|
@@ -19,14 +19,14 @@ Total effort: typically a few hours depending on how weird the upstream API is a
|
|
|
19
19
|
| Provider ID | Class | File | Notes |
|
|
20
20
|
|---|---|---|---|
|
|
21
21
|
| `aws-bedrock-claude-invoke` | `BedrockAgent` | `bedrock-agent.ts` | Anthropic Messages API via AWS SDK |
|
|
22
|
-
| `kimi` | `KimiAgent` | `kimi-agent.ts` | OpenAI-compatible, streaming via `fetchChatCompletionStream` (usage arrives in its own `choices: []` chunk), thinking (reasoning_content). Two API generations, branched on modelId: **K3** — vision (base64 data URLs only, no public-URL input), thinking always-on server-side, sole knob is top-level `reasoning_effort` (enum currently just `"max"`); **K2.6** — no vision, no `reasoning_effort` field at all, only the `thinking:{type:'enabled'|'disabled'}` toggle. Never send the K2.x `thinking` parameter to K3 |
|
|
22
|
+
| `kimi` | `KimiAgent` | `kimi-agent.ts` | OpenAI-compatible, streaming via `fetchChatCompletionStream` (usage arrives in its own `choices: []` chunk), thinking (reasoning_content). Cached tokens via `cachedPromptTokens` (every known usage key — which one Kimi reports isn't pinned). Two API generations, branched on modelId: **K3** — vision (base64 data URLs only, no public-URL input), thinking always-on server-side, sole knob is top-level `reasoning_effort` (enum currently just `"max"`); **K2.6** — no vision, no `reasoning_effort` field at all, only the `thinking:{type:'enabled'|'disabled'}` toggle. Never send the K2.x `thinking` parameter to K3 |
|
|
23
23
|
| `deepseek` | `DeepSeekAgent` | `deepseek-agent.ts` | OpenAI-compatible, streaming via `fetchChatCompletionStream` (first chunk has `content: null` + `reasoning_content: ''`; the usage chunk still carries `choices`), thinking (reasoning_content). Vision per model: `deepseek-flash` (V4.1-Flash) sees `image_url` data URLs; `deepseek-v4-pro` accepts the block but ignores it (live-probed 2026-09-24 with a 64×64 blue PNG) — registry `image` flag gates it |
|
|
24
24
|
| `minimax` | `MiniMaxAgent` | `minimax-agent.ts` | Anthropic Messages API (x-api-key), streaming via `fetchAnthropicStream` — note `message_start.usage` is all zeros and the real input / cache counts arrive in `message_delta.usage` (the accumulator takes them from there). Two thinking schemes, branched on registry `thinking.mode`: **M3** (default) — vision, thinking off by default and enabled via `thinking:{type:'adaptive'}` (no levels, no budget_tokens), no `promptCaching` in the manifest on purpose (official docs scope `cache_control` to M2.x only; M3 has passive automatic caching); **M2.7** — no vision, thinking always-on (manual budget_tokens), prompt caching (5m, M2.7 doesn't report cache_creation_input_tokens) |
|
|
25
25
|
| `qwen` | `QwenAgent` | `qwen-agent.ts` | Aliyun Bailian Anthropic-compatible API, streaming via `fetchAnthropicStream` (SSE frames come as `data:{…}` with no space after the colon — the reader accepts both), thinking enable/disable both honored, prompt caching (5m only), vision on Plus, max no vision |
|
|
26
26
|
| `hunyuan` | `HunyuanAgent` | `hunyuan-agent.ts` | Tencent Hy3 preview via OpenAI-compatible (tokenhub.tencentmaas.com), streaming via `fetchChatCompletionStream` (`stream_options.include_usage` accepted; usage in its own `choices: []` chunk), reasoning_effort low/high (no_think = omit), automatic prompt caching (cached_tokens), no vision |
|
|
27
27
|
| `doubao` | `DoubaoAgent` | `doubao-agent.ts` | ByteDance Doubao Seed-2.0 (pro/lite/mini/code) on Volcengine Ark, OpenAI-compatible, streaming via `fetchChatCompletionStream` (usage in its own `choices: []` chunk) + `thinking:{type:'enabled'/'disabled'}` (no `auto`), automatic prompt caching, no vision |
|
|
28
28
|
| `zhipu` | `ZhipuAgent` | `zhipu-agent.ts` | Zhipu GLM-5.x on open.bigmodel.cn, OpenAI-compatible, streaming via `fetchChatCompletionStream` (tool-call arguments arrive complete in one fragment; usage chunk carries `choices`). Output cap via `max_tokens` only (`max_completion_tokens` is silently ignored). Thinking branched on modelId: **glm-5.3\*** always thinks — always sends `thinking:{type:'enabled',clear_thinking:false}`, `reasoning_effort` only when low/high/max (`disabled` / other efforts 400); other ids (glm-5.2) honour `thinking:{type:'disabled'}`. Vision per model: `glm-5.3-flash` / `glm-5.3-flashx` see `image_url` data URLs; `glm-5.3` / `glm-5.2` are text-only and 400 on any image part (code 1210, classified `multimodal_4xx` so leftover history images degrade). Automatic caching, `prompt_tokens_details.cached_tokens` (included in `prompt_tokens`, subtracted). |
|
|
29
|
-
| `openai` | `OpenAIAgent` | `openai-agent.ts` | **Generic OpenAI-compatible**. Use for native OpenAI or any third-party OpenAI gateway (incl. Ollama / llama.cpp). User fills endpoint + model id; capabilities openly editable in the form. Streaming via `fetchChatCompletionStream` — always sends `stream: true` + `stream_options: { include_usage: true }` (OpenAI's own field; Ollama ≥ 0.3 / vLLM / LM Studio accept it too). A gateway that rejects unknown fields would 400 — accepted trade-off, no non-streaming fallback. Forgiving about cache field names (`prompt_tokens_details.cached_tokens`, `prompt_cache_hit_tokens`, `cache_read_tokens`
|
|
29
|
+
| `openai` | `OpenAIAgent` | `openai-agent.ts` | **Generic OpenAI-compatible**. Use for native OpenAI or any third-party OpenAI gateway (incl. Ollama / llama.cpp). User fills endpoint + model id; capabilities openly editable in the form. Streaming via `fetchChatCompletionStream` — always sends `stream: true` + `stream_options: { include_usage: true }` (OpenAI's own field; Ollama ≥ 0.3 / vLLM / LM Studio accept it too). A gateway that rejects unknown fields would 400 — accepted trade-off, no non-streaming fallback. Forgiving about cache field names (`cachedPromptTokens`: first value > 0 of `prompt_tokens_details.cached_tokens`, top-level `cached_tokens`, `prompt_cache_hit_tokens`, `cache_read_tokens`) and reasoning field names (`reasoning_content` for OpenAI o-series/DeepSeek, `reasoning` for Ollama/llama.cpp). |
|
|
30
30
|
| `anthropic` | `AnthropicAgent` | `anthropic-agent.ts` | **Generic Anthropic Messages**. Use for api.anthropic.com or any Anthropic-compatible gateway. User fills endpoint + model id. Streaming via `fetchAnthropicStream` (the gateway must support `stream: true` SSE — the reference Anthropic behaviour). Standard `cache_control: ephemeral` with optional `ttl: '1h'`. |
|
|
31
31
|
| `mimo-token-plan-china` | `AnthropicAgent` | (reuses `anthropic-agent.ts`) | Xiaomi MiMo (UI name "Xiaomi MiMo"; the id keeps its historical name so existing configs don't break) — Anthropic-compatible gateway, streams like `anthropic` (gateway built for Claude Code, which always streams; not live-probed); no dedicated class, just a default endpoint in `model-runtime.ts` (`token-plan-cn.xiaomimimo.com/anthropic`). The key type picks the endpoint: Token Plan `tp-`/`ttp-` keys only work on `token-plan-{cn,sgp,ams}.xiaomimimo.com/anthropic`, pay-as-you-go `sk-` keys only on `api.xiaomimimo.com/anthropic` — all four ship as `endpointPresets` (pay-as-you-go live-probed 2026-09-24; sgp / ams from the docs only). V2.6 (`mimo-v2.6-pro` / `-flash`, both vision, 1M / 131072) is current; `mimo-v2.5(-pro)` kept in the template until the official 2026-10-21 deprecation, then drop |
|
|
32
32
|
| `aws-bedrock-mantle` | `MantleAgent` | `mantle-agent.ts` | OpenAI GPT-6.1 Sol, GPT-6 (Astra / Sol / Luna) and GPT-5.6 (Sol / Terra / Luna) on Amazon Bedrock via the **OpenAI Responses API** (not Completions/Converse/InvokeModel). Vision, reasoning via `reasoning.effort` (low/med/high/xhigh/max, no budget_tokens; on 5.6 `max` is accepted live but absent from the model's own validation list — measured ~6% more reasoning_tokens than xhigh; on Astra it's on the official list), `text.verbosity` (low/medium/high, same on both generations). **Region split** (live-probed 2026-09-24): us-east-1 serves GPT-6.1 Sol + GPT-6 Sol / Luna + every 5.6 model (404s Astra; GPT-6.1 Sol probed 2026-09-30, us-east-1 only); us-east-2 serves only the 5.6 trio; us-west-2 serves Astra + 5.6 Terra / Luna. Template ships all three as `endpointPresets`, default is now us-east-1 (`openai.gpt-6-sol` default model); Astra agents pick us-west-2 in the form; `extractRegionFromHost` falls back to us-east-1. All GPT-6 variants: 1,050,000 context / 128,000 max output, same effort (none/low/medium/high/xhigh/max) + verbosity surface; input beyond 272K tokens bills at 2x, so cap `context.maxTokens` in agent.yaml unless that's intended. Never surfaces reasoning summaries — `output[].reasoning.summary` stays empty even with `reasoning.summary=detailed` while `reasoning_tokens` counts up (verified live), so no thinking text in the UI is upstream behavior, not a parsing gap. Streams (`stream: true`, Responses API SSE folded by `readResponsesStream` in the same file): `response.output_text.delta` frames go out live, the terminal `response.completed` / `response.incomplete` frame carries the full final response object; this host sends **no** `data: [DONE]` (live-probed 2026-09-29). |
|
|
@@ -57,7 +57,7 @@ export interface ModelRuntime {
|
|
|
57
57
|
}
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
`AgentLoop` handles the tool execution loop (call model → execute tools → loop). You only implement `callModel(signal, onDelta?)` — the provider-specific API call. It always returns a `Promise<ModelCallResult>` containing the complete response; the base class `run()` method yields events per loop iteration. **Streaming is optional and per-provider**: if the upstream API streams, call `onDelta({ type: 'text_delta' | 'thinking_delta', text })` per chunk as it arrives — the loop yields those as `text_delta` / `thinking_delta` events during the call and re-arms the per-call timeout on each (so it becomes an idle timeout) — then still return the whole result. For data that arrives but has nothing to show (ping events, tool-argument fragments, empty reasoning chunks, SSE comment frames) call `onDelta(ACTIVITY_DELTA)` (exported from `agent-loop.ts`; `type: 'activity'`, empty text): it only re-arms the idle timer and never reaches the event stream, so a long tool-call argument or a silent reasoning stretch doesn't trip the idle timeout. The shared readers do this for you — `readSseJson`'s per-chunk callback is wired to it in `fetchAnthropicStream` / `fetchChatCompletionStream` / `readResponsesStream`, and the Bedrock agent reports it once per stream event. A non-streaming provider would simply ignore `onDelta`; today every built-in provider streams — Bedrock, the Anthropic-Messages providers (anthropic / mimo / minimax / qwen), the OpenAI-family (openai / deepseek / kimi / zhipu / doubao / hunyuan) and Mantle (aws-bedrock-mantle / aws-bedrock-openai). The fold from Anthropic stream events into a `ModelCallResult` lives in `anthropic-stream.ts` (`AnthropicStreamAccumulator`); for an HTTP gateway speaking Anthropic Messages, `fetchAnthropicStream({ url, headers, body, signal, onDelta, tag })` in the same file is the whole `callModel` body — it posts with `stream: true`, parses the SSE with the generic `readSseJson` reader in `sse.ts` (also skips the OpenAI `[DONE]` terminator), maps a mid-stream `event: error` frame onto the `[tag] <status> <type>: <message>` error shape `classifyModelError` parses, and re-throws `AbortError` if the body ended at the abort instant. For an OpenAI-compatible `chat/completions` endpoint, `fetchChatCompletionStream({ url, headers, body, signal, onDelta, tag })` in `openai-chat-stream.ts` is the transport half of `callModel` — it posts with `stream: true` + `stream_options: { include_usage: true }`, folds the `chat.completion.chunk` frames (text / `reasoning_content` (or the Ollama `reasoning` alias) appended and reported via `onDelta`; `tool_calls[]` fragments matched by `index`, the first fragment carrying `id` + `function.name`, later ones appending `function.arguments`; `finish_reason` / `usage` last-non-null-wins — the usage chunk arrives with `choices` on deepseek / zhipu and as `choices: []` on kimi / doubao / hunyuan) back into the non-streaming `choices[0].message` + `finishReason` + `usage` shape, and throws today's `[<Tag>Agent] API error <status>: <body>` on non-2xx — the
|
|
60
|
+
`AgentLoop` handles the tool execution loop (call model → execute tools → loop). You only implement `callModel(signal, onDelta?)` — the provider-specific API call. It always returns a `Promise<ModelCallResult>` containing the complete response; the base class `run()` method yields events per loop iteration. **Streaming is optional and per-provider**: if the upstream API streams, call `onDelta({ type: 'text_delta' | 'thinking_delta', text })` per chunk as it arrives — the loop yields those as `text_delta` / `thinking_delta` events during the call and re-arms the per-call timeout on each (so it becomes an idle timeout) — then still return the whole result. For data that arrives but has nothing to show (ping events, tool-argument fragments, empty reasoning chunks, SSE comment frames) call `onDelta(ACTIVITY_DELTA)` (exported from `agent-loop.ts`; `type: 'activity'`, empty text): it only re-arms the idle timer and never reaches the event stream, so a long tool-call argument or a silent reasoning stretch doesn't trip the idle timeout. The shared readers do this for you — `readSseJson`'s per-chunk callback is wired to it in `fetchAnthropicStream` / `fetchChatCompletionStream` / `readResponsesStream`, and the Bedrock agent reports it once per stream event. A non-streaming provider would simply ignore `onDelta`; today every built-in provider streams — Bedrock, the Anthropic-Messages providers (anthropic / mimo / minimax / qwen), the OpenAI-family (openai / deepseek / kimi / zhipu / doubao / hunyuan) and Mantle (aws-bedrock-mantle / aws-bedrock-openai). The fold from Anthropic stream events into a `ModelCallResult` lives in `anthropic-stream.ts` (`AnthropicStreamAccumulator`); for an HTTP gateway speaking Anthropic Messages, `fetchAnthropicStream({ url, headers, body, signal, onDelta, tag })` in the same file is the whole `callModel` body — it posts with `stream: true`, parses the SSE with the generic `readSseJson` reader in `sse.ts` (also skips the OpenAI `[DONE]` terminator), maps a mid-stream `event: error` frame onto the `[tag] <status> <type>: <message>` error shape `classifyModelError` parses, and re-throws `AbortError` if the body ended at the abort instant. For an OpenAI-compatible `chat/completions` endpoint, `fetchChatCompletionStream({ url, headers, body, signal, onDelta, tag })` in `openai-chat-stream.ts` is the transport half of `callModel` — it posts with `stream: true` + `stream_options: { include_usage: true }`, folds the `chat.completion.chunk` frames (text / `reasoning_content` (or the Ollama `reasoning` alias) appended and reported via `onDelta`; `tool_calls[]` fragments matched by `index`, the first fragment carrying `id` + `function.name`, later ones appending `function.arguments`; `finish_reason` / `usage` last-non-null-wins — the usage chunk arrives with `choices` on deepseek / zhipu and as `choices: []` on kimi / doubao / hunyuan) back into the non-streaming `choices[0].message` + `finishReason` + `usage` shape, and throws today's `[<Tag>Agent] API error <status>: <body>` on non-2xx — `chatCompletionResult(folded, usage, startTime)` in `openai-chat-format.ts` then turns the folded reply into a `ModelCallResult`, with the usage numbers the agent computed itself. Mantle's Responses API needs no accumulator at all: `readResponsesStream` (module-level in `mantle-agent.ts`) reports `response.output_text.delta` / `response.reasoning_summary_text.delta` live and keeps the `response` object off the terminal `response.completed` / `response.incomplete` frame, which is the full final response in exactly the non-streaming JSON shape — the existing `output[]` / `incomplete_details` / `usage` parse runs on it unchanged; a stream that ends without a terminal frame throws the same `MantleEmptyResponse` marker as the empty-`output[]` glitch so `classifyModelError`'s `empty_response` retry covers both.
|
|
61
61
|
|
|
62
62
|
Two important constraints:
|
|
63
63
|
|
|
@@ -103,39 +103,48 @@ For providers where the API returns `prompt_tokens` inclusive of cached (e.g. Ki
|
|
|
103
103
|
Create `packages/server/src/agents/<provider>-agent.ts`. Extend `AgentLoop` and implement `callModel()`.
|
|
104
104
|
|
|
105
105
|
Reference implementations:
|
|
106
|
-
- OpenAI-compatible: [kimi-agent.ts](../../../packages/server/src/agents/kimi-agent.ts), [deepseek-agent.ts](../../../packages/server/src/agents/deepseek-agent.ts)
|
|
106
|
+
- OpenAI-compatible: [kimi-agent.ts](../../../packages/server/src/agents/kimi-agent.ts), [deepseek-agent.ts](../../../packages/server/src/agents/deepseek-agent.ts) — conversion helpers in [openai-chat-format.ts](../../../packages/server/src/agents/openai-chat-format.ts)
|
|
107
|
+
- Anthropic Messages over HTTP: [qwen-agent.ts](../../../packages/server/src/agents/qwen-agent.ts), [minimax-agent.ts](../../../packages/server/src/agents/minimax-agent.ts) — body helpers in [anthropic-request.ts](../../../packages/server/src/agents/anthropic-request.ts)
|
|
107
108
|
- AWS native: [bedrock-agent.ts](../../../packages/server/src/agents/bedrock-agent.ts)
|
|
108
109
|
|
|
109
|
-
Rough shape for OpenAI-compatible providers:
|
|
110
|
+
Rough shape for OpenAI-compatible providers — the format conversion is shared (`openai-chat-format.ts`); what the class writes itself is the provider's quirks: the request body (output-cap field, thinking / reasoning knobs), the image mode, the usage mapping and the error tag:
|
|
110
111
|
|
|
111
112
|
```ts
|
|
112
113
|
import { AgentLoop } from './agent-loop.js'
|
|
113
|
-
import type {
|
|
114
|
+
import type { ModelCallResult, ModelDelta, ToolDef } from './agent-loop.js'
|
|
115
|
+
import { fetchChatCompletionStream } from './openai-chat-stream.js'
|
|
116
|
+
import { chatCompletionResult, toChatMessages, toChatTools } from './openai-chat-format.js'
|
|
114
117
|
|
|
115
118
|
export class MyProviderAgent extends AgentLoop {
|
|
116
119
|
constructor(config: MyConfig) { super(config.tools); ... }
|
|
117
120
|
|
|
118
121
|
protected async callModel(signal, onDelta?): Promise<ModelCallResult> {
|
|
119
122
|
const startTime = Date.now()
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
// AnthropicMessage[] → chat messages; image mode = the provider's vision support:
|
|
124
|
+
// 'parts' (image_url parts) | 'if-image' (parts only when an image is present) | 'drop' (text only)
|
|
125
|
+
const messages = toChatMessages(this.config.systemPrompt, this.messages, 'parts')
|
|
126
|
+
const tools = toChatTools(this.config.tools) // ToolDef[] → OpenAI function calling format
|
|
122
127
|
|
|
123
|
-
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
+
const body = { model, messages, max_tokens /* or max_completion_tokens */, ...(tools.length > 0 ? { tools } : {}) }
|
|
129
|
+
// + this provider's thinking / reasoning fields
|
|
130
|
+
|
|
131
|
+
// POSTs with stream: true, reports text / reasoning chunks through onDelta,
|
|
132
|
+
// returns the folded { message, finishReason, usage, ttftMs }
|
|
133
|
+
const folded = await fetchChatCompletionStream({ url, headers, body, signal, onDelta, tag: 'MyProviderAgent' })
|
|
128
134
|
|
|
129
|
-
|
|
130
|
-
|
|
135
|
+
// Per-provider usage math: which field carries cached tokens, how they're deducted.
|
|
136
|
+
const cached = ...
|
|
137
|
+
return chatCompletionResult(folded, { inputTokens: prompt - cached, outputTokens, cacheReadInputTokens: cached }, startTime)
|
|
138
|
+
}
|
|
131
139
|
}
|
|
132
140
|
```
|
|
133
141
|
|
|
134
142
|
**Things that bite**:
|
|
135
143
|
|
|
136
|
-
- **Tool results format.** Anthropic stores tool results as `{ role: 'user', content: [{ type: 'tool_result', ... }] }`. OpenAI-compatible APIs need separate `{ role: 'tool', tool_call_id, content }` messages.
|
|
137
|
-
- **Thinking/reasoning replay.** If the provider returns `reasoning_content` and requires it in follow-up messages (Kimi, DeepSeek with tool calls),
|
|
138
|
-
- **Reasoning field name varies.** OpenAI o-series/DeepSeek emit reasoning in `message.reasoning_content`; Ollama / llama.cpp's OpenAI-compat layer uses `message.reasoning`. `
|
|
144
|
+
- **Tool results format.** Anthropic stores tool results as `{ role: 'user', content: [{ type: 'tool_result', ... }] }`. OpenAI-compatible APIs need separate `{ role: 'tool', tool_call_id, content }` messages, and tool-result images ride in a user message right after them. `toChatMessages()` in openai-chat-format.ts does both.
|
|
145
|
+
- **Thinking/reasoning replay.** If the provider returns `reasoning_content` and requires it in follow-up messages (Kimi, DeepSeek with tool calls), it must be stored in `result.assistantBlocks` as a `{ type: 'thinking', thinking: '...' }` block and replayed as `reasoning_content` — `chatCompletionResult()` stores it and `toChatMessages()` replays it.
|
|
146
|
+
- **Reasoning field name varies.** OpenAI o-series/DeepSeek emit reasoning in `message.reasoning_content`; Ollama / llama.cpp's OpenAI-compat layer uses `message.reasoning`. The shared stream accumulator (`ChatCompletionAccumulator` in `openai-chat-stream.ts`) folds both into one reasoning stream (`reasoning_content ?? reasoning`), so every `fetchChatCompletionStream` provider gets it. A vendor-specific class should read whichever its API uses.
|
|
147
|
+
- **Malformed tool arguments.** Parse tool-call arguments with `parseToolInput(json, toolName)` (`tool-input.ts`) — every built-in path does (Anthropic stream accumulator, `chatCompletionResult`, Mantle). It returns `{ input, error? }` and never throws: empty / missing arguments are `{}` with no error; arguments that aren't valid JSON or aren't a plain JSON object (`null`, an array, a scalar) become `{}` plus an `error` and a `[ToolInput]` warn (tool name and the first 200 chars). Pass the error through as `inputError` on the `toolCalls[]` entry (keep `input: {}` in `assistantBlocks`) — the loop then skips that call and returns an `is_error` result asking the model to resend complete JSON, instead of running the tool with `{}`. A `max_tokens` stop never runs any of the reply's tool calls; the loop answers each with an `is_error` result itself.
|
|
139
148
|
- **Cancellation.** The caller passes `signal`; pass it to `fetch()` directly.
|
|
140
149
|
- **Max tokens.** Use `config.maxTokens ?? resolveMaxOutputTokens(config.modelId)` for consistency.
|
|
141
150
|
- **Prompt caching and thinking** are optional per provider. If unsupported, silently ignore.
|
|
@@ -336,7 +345,7 @@ myprovider:
|
|
|
336
345
|
|
|
337
346
|
### Effort labels are Halo-side only
|
|
338
347
|
|
|
339
|
-
`effortPresets: [low, medium, high]` is purely a UI affordance — it lives on the form and gets translated to `budget_tokens` (or whatever the provider wants) inside the agent class via an effort→budget table. Don't write `effort=medium` directly to the wire. Provider doesn't know what `medium` means.
|
|
348
|
+
`effortPresets: [low, medium, high]` is purely a UI affordance — it lives on the form and gets translated to `budget_tokens` (or whatever the provider wants) inside the agent class via an effort→budget table (for the Anthropic-format providers, `effortToBudget()` in `anthropic-request.ts`). Don't write `effort=medium` directly to the wire. Provider doesn't know what `medium` means.
|
|
340
349
|
|
|
341
350
|
Probe each preset's actual budget against the model's `maxOutputTokens`. We hit this with MiniMax: `low` (1024 budget) caused `stop=max_tokens` before any thinking completed — left it out of the preset list.
|
|
342
351
|
|
|
@@ -378,7 +387,7 @@ The `MantleEmptyResponse` retry guard in session-manager is unrelated to this an
|
|
|
378
387
|
|
|
379
388
|
Keeping it a hard-coded switch is deliberate for now:
|
|
380
389
|
- Each provider has enough quirks (auth, response format, tool calling conventions, thinking replay rules) that a generic "provider interface with fetch client" would leak abstractions
|
|
381
|
-
- Each provider independently extends `AgentLoop` — no shared OpenAI-compatible base class, to avoid coupling when APIs diverge; the shared
|
|
390
|
+
- Each provider independently extends `AgentLoop` — no shared OpenAI-compatible base class, to avoid coupling when APIs diverge; the shared pieces are helper functions, not a base class — transport (`fetchChatCompletionStream`, `fetchAnthropicStream`) and format (`openai-chat-format.ts`: message / tool / result conversion; `anthropic-request.ts`: system / tools / `cache_control` placement and the thinking-budget math) — each agent still owns its request body, thinking shape, usage mapping and error tag
|
|
382
391
|
- The Agent management UI reads the manifest directly; a runtime-registered plugin would need to notify the frontend dynamically
|
|
383
392
|
- One provider lands every few months, not every week
|
|
384
393
|
|
|
@@ -393,6 +402,8 @@ If / when Halo has 5+ providers and the switch becomes unwieldy, the next step w
|
|
|
393
402
|
- Bedrock (Anthropic native): [packages/server/src/agents/bedrock-agent.ts](../../../packages/server/src/agents/bedrock-agent.ts)
|
|
394
403
|
- Kimi (OpenAI-compatible + vision): [packages/server/src/agents/kimi-agent.ts](../../../packages/server/src/agents/kimi-agent.ts)
|
|
395
404
|
- DeepSeek (OpenAI-compatible, no vision): [packages/server/src/agents/deepseek-agent.ts](../../../packages/server/src/agents/deepseek-agent.ts)
|
|
405
|
+
- OpenAI-family format helpers: [packages/server/src/agents/openai-chat-format.ts](../../../packages/server/src/agents/openai-chat-format.ts)
|
|
406
|
+
- Anthropic-family body helpers: [packages/server/src/agents/anthropic-request.ts](../../../packages/server/src/agents/anthropic-request.ts)
|
|
396
407
|
- Manifest examples: [packages/server/templates/models/](../../../packages/server/templates/models/)
|
|
397
408
|
- Models registry loader: [packages/server/src/config.ts](../../../packages/server/src/config.ts) (`getModelsRegistry`)
|
|
398
409
|
- Architecture context: [design/architecture.md#modelruntime](../design/architecture.md#modelruntime--llm-interaction-layer-provider-agnostic)
|