@turmind/halo 1.6.0 → 1.6.2
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/58.d26b1c4f14404226.js +1 -0
- package/admin-out/_next/static/chunks/989-ddba0eb72566da23.js +1 -0
- package/admin-out/_next/static/chunks/app/page-1d27029caba48956.js +1 -0
- package/admin-out/_next/static/chunks/webpack-74f577c1b094f942.js +1 -0
- package/admin-out/_next/static/css/3b4dcd0ac6b8eae8.css +1 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +5 -5
- package/bundled-docs/design/agent.md +6 -4
- package/bundled-docs/design/canvas-extensions.md +30 -4
- package/bundled-docs/design/cron.md +24 -11
- package/bundled-docs/design/prompt-system.md +10 -5
- package/bundled-docs/design/session.md +4 -1
- package/bundled-docs/design/storage.md +36 -11
- package/bundled-docs/design/ws.md +7 -5
- package/bundled-docs/dev/add-model-provider.md +17 -11
- package/bundled-docs/dev/api.md +25 -12
- package/bundled-docs/dev/deploy.md +3 -0
- package/bundled-docs/dev/desktop-packaging.md +11 -5
- package/bundled-docs/dev/env.md +1 -1
- package/bundled-docs/dev/previews.md +26 -0
- package/bundled-docs/guide/agents.md +6 -6
- package/bundled-docs/guide/cli.md +5 -4
- package/bundled-docs/guide/skills.md +12 -6
- package/bundled-docs/guide/workspace.md +1 -1
- package/bundled-docs/requirements/agent.md +5 -0
- package/bundled-docs/requirements/chat.md +9 -5
- package/bundled-docs/requirements/command.md +4 -0
- package/bundled-docs/requirements/editor.md +20 -6
- package/bundled-docs/requirements/session.md +1 -1
- package/bundled-docs/requirements/skill.md +15 -9
- package/dist/index.js +2332 -1348
- package/package.json +1 -1
- package/templates/models/anthropic.yaml +109 -13
- package/templates/models/aws-bedrock-claude-invoke.yaml +39 -13
- package/templates/models/aws-bedrock-mantle.yaml +60 -28
- package/templates/models/aws-bedrock-openai.yaml +52 -29
- package/templates/models/deepseek.yaml +19 -10
- package/templates/models/doubao.yaml +72 -20
- package/templates/models/hunyuan.yaml +79 -15
- package/templates/models/kimi.yaml +63 -3
- package/templates/models/mimo-token-plan-china.yaml +9 -1
- package/templates/models/minimax.yaml +9 -3
- package/templates/models/openai.yaml +120 -17
- package/templates/models/qwen.yaml +103 -6
- package/templates/models/zhipu.yaml +9 -1
- package/templates/schema.sql +1 -1
- package/templates/skills/cron/SKILL.md +40 -3
- package/templates/skills/cron/manage-cron.py +140 -15
- package/templates/skills/skill/SKILL.md +22 -11
- package/templates/skills/workspace/share.md +5 -2
- package/templates/skills/workspace/stage.py +51 -14
- package/admin-out/_next/static/chunks/220.7566bc89f3d98993.js +0 -1
- package/admin-out/_next/static/chunks/989-a7f752480eaaa2dc.js +0 -1
- package/admin-out/_next/static/chunks/app/page-8173112f256bc483.js +0 -1
- package/admin-out/_next/static/chunks/webpack-dc73047e0279979b.js +0 -1
- package/admin-out/_next/static/css/981c9e5610ce535a.css +0 -1
- /package/admin-out/_next/static/{_F_Fkyh9PainUNY82EEjY → VgAe62AuGEiYrF4JQ7WQe}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{_F_Fkyh9PainUNY82EEjY → VgAe62AuGEiYrF4JQ7WQe}/_ssgManifest.js +0 -0
|
@@ -29,6 +29,7 @@ Schedules are **durable** — the source of truth is the `cron_jobs` table; in-m
|
|
|
29
29
|
|
|
30
30
|
- **Recurring**: standard cron expression (`job.schedule`), optional `timezone`, optional **active window** `[activeFrom, activeUntil)` (epoch ms, `active_from` / `active_until`, NULL = unbounded; `CRON_MIGRATIONS` slot 2). The window is passed to croner as native `startAt` / `stopAt` (`activeWindowOptions` in `runner.ts`; `startAt` is `activeFrom − 1 ms` because croner's startAt is exclusive), so fires outside it simply never happen — no `cron_runs` row. "Pause until X" = push `activeFrom` to X; there is no skip-list for a gap in the middle. Past `activeUntil` the job stays `enabled` (so it can be extended later) but `scheduleJob` creates no croner and logs `past activeUntil; not scheduled` once — the id goes into `_expired` so reconcile's "fingerprint cached but not in `_active`" retry branch treats it as handled instead of re-scheduling (and re-logging) every 10s. The REST `nextRunAt` uses the same options, so the list shows the first post-pause fire; the admin badges a job "active from …" / "ended". Run-now ignores the window. Recurring only: a window together with `runAt` is rejected at every write surface (REST on the merged row, skill helper).
|
|
31
31
|
- **One-shot**: `job.runAt` (epoch ms) — croner fires once at that instant. After it completes, `finalize` sets `enabled=0` so it never re-fires. A `runAt` already in the past at schedule time is marked `lastRunStatus='missed'` and disabled (rather than firing immediately or retrying every reconcile). The `run_at` column is `CRON_MIGRATIONS` slot 0 in `db/cron-db.ts`, run through the shared `runMigrations` / `PRAGMA user_version` mechanism (see [storage.md](storage.md#schema-change-rules)).
|
|
32
|
+
- **Folder + stateless** (`CRON_MIGRATIONS` slot 4, v5): `cron_jobs.working_dir` — the folder the run focuses on, passed as `halo cli --dir` (same semantics as a sub-agent's `working_dir`: the folder's `.halo/INSTRUCTIONS.md` chain joins the prompt, plus a `Working directory:` line); stored workspace-relative, NULL = the workspace root; validated at every write surface (inside the workspace, existing — REST re-checks a stored folder when `workspacePath` moves). `cron_jobs.stateless` (0/1) — **new session every run**: each fire gets a fresh root session `cron-<id>-<base36 ms>` (`perRunSessionId`, same no-double-prefix rule as `effectiveSessionId`), so nothing accumulates across fires; mutually exclusive with `session_id` (`400` on the merged row). `cron_runs.session_id` — the session that run used, the only record of a stateless run's id (NULL on rows from before v5).
|
|
32
33
|
|
|
33
34
|
### Trigger-mode exclusivity (the db contract)
|
|
34
35
|
|
|
@@ -39,33 +40,45 @@ Exactly **one** of `schedule` / `runAt` is set per job. `runner.ts` `scheduleJob
|
|
|
39
40
|
- Clearing the active side with nothing to replace it (`schedule: ''` on a recurring job, `runAt: null` on an at-mode job) → `400 schedule or runAt required`. A triggerless job would sit `enabled` and never fire.
|
|
40
41
|
- A non-number, non-null `runAt` is rejected (`400`) *before* the exclusivity check — otherwise garbage types slip past `typeof body.runAt === 'number'` and land in the db as-is.
|
|
41
42
|
|
|
43
|
+
The skill helper `manage-cron.py update` follows the same rules (`--schedule` / `--run-at` switch the mode, both or neither left is refused); `test/manage-cron-py.test.ts` pins its parity with REST.
|
|
44
|
+
|
|
42
45
|
### Hot-reload + out-of-band edits
|
|
43
46
|
|
|
44
47
|
REST route mutations call `scheduleJob` / `unscheduleJob` directly. But the `cron` skill (and manual ops) edit `cron.db` over a *different* sqlite connection. `reconcileFromDb` (10s timer) catches those:
|
|
45
48
|
|
|
46
49
|
- Fast path: `PRAGMA data_version` flips only when *another* connection commits. If unchanged since last pass, skip the full select entirely — one pragma read (~µs) vs. a select-all + per-row fingerprint compare. The pragma (and the run-pruning `NOT IN (subquery)` below) needs the raw better-sqlite3 handle, which the runner gets from the shared `rawSqlite(db)` helper (`db/raw-sqlite.ts`) instead of re-spelling drizzle's `$client` / `.session.client` cast at each site — it did, three times, each with its own hand-written method shape.
|
|
47
|
-
- On change: diff db rows against the in-memory `_fingerprint` map (`enabled
|
|
50
|
+
- On change: diff db rows against the in-memory `_fingerprint` map (`jobFingerprint`: enabled, schedule, runAt, timezone, activeFrom, activeUntil, agentId, userPrompt, workingDir, stateless); schedule new rows, unschedule deleted ones, re-instantiate croner only for rows whose fingerprint changed. Broadcasts a coalesced `cron:job_changed` per affected job.
|
|
48
51
|
|
|
49
52
|
## Execution (`runJob`)
|
|
50
53
|
|
|
51
54
|
Each fire:
|
|
52
55
|
|
|
53
|
-
1. **Concurrency guard**: resolve the job's session first (`effectiveSessionId` — `job.sessionId`, else the job's own session, see step 4), then check three conditions in order; the first hit inserts a `cron_runs` row with `status='skipped'` and that `failureReason`, broadcasts, and bails without spawning. Applies to both scheduled fires and manual run-now clicks.
|
|
56
|
+
1. **Concurrency guard**: resolve the job's session first (`effectiveSessionId` — `job.sessionId`, else the job's own session, see step 4; a stateless job's fresh `perRunSessionId`, which the two session-level checks never hit), then check three conditions in order; the first hit inserts a `cron_runs` row with `status='skipped'` and that `failureReason`, broadcasts, and bails without spawning. Applies to both scheduled fires and manual run-now clicks.
|
|
54
57
|
- Same `jobId` already running in this process (in-memory `_inflight` set) → `previous run still in progress`.
|
|
55
58
|
- Another job's cli already on the same session (`_inflightSessions`, keyed `realpath(workspace) + sessionId` so two spellings of one workspace collide) → `session <id> busy: another cron run is in progress`. Two jobs may pick the same session.
|
|
56
59
|
- The server itself is mid-turn anywhere in that session's tree (`hasActiveWorkInTree` on the workspace's live SessionManager, looked up with the registry's `peek` — cache-only, never builds a manager, whose constructor would run the boot reconcile as a side effect) → `session <id> busy: a turn is running in it`. In practice this is a picked session someone is chatting in.
|
|
57
60
|
|
|
58
61
|
Two overlapping writers would double-write the same on-disk session state; SessionManager's per-session lock is in-process and can't see across cli children, so these sets are the cheapest place to enforce serialization. They are in-memory, but their restart blind spot is covered: the boot-time orphan sweep (below) re-enters stale jobIds and session keys before croner re-arms. The runner reaches the server's managers through `setCronSessionRegistry(registry)` (called in `index.ts` next to `setRelayRegistry`); unset — tests, a bare CLI — means there are no server-side sessions to guard.
|
|
59
|
-
2. Insert a `cron_runs` row with `status='running'` up front (UI sees it mid-flight); broadcast `cron:run_changed`.
|
|
60
|
-
3. Check `job.workspacePath` exists — fail loudly if the workspace was moved/deleted.
|
|
61
|
-
4. Spawn `halo cli -a <agent> -s <session> -w <workspace> [--access <level>]`, prompt on **stdin** (avoids Windows argv length limits), with a fixed `[Unattended cron run — nobody will answer questions …]` line stamped ahead of `user_prompt` on every fire (`UNATTENDED_PREAMBLE` in `runner.ts`) — the model can't see the `cron-*` session id, and prompts typed into the admin Cron form carry no such notice, so the runner adds it rather than trusting the prompt author. **Session strategy**: every job runs in one stable session — its own by default, or an existing **root** session picked on the job (`cron_jobs.session_id`, nullable; `CRON_MIGRATIONS` slot 1 in `db/cron-db.ts`). The job's own session id **is the job id** (generated ids are already `cron-<ts>-<rand>`; a custom id without that prefix gets `cron-<id>`) — `effectiveSessionId` in `runner.ts` is the one copy of the rule, and the REST list returns its result as `effectiveSessionId` so the admin never re-derives it. Older builds always prefixed, giving `cron-cron-…`; `CRON_MIGRATIONS` slot 3 (v4) pins every job that ever spawned a cli (`last_run_id` set, or a non-skipped run row — covers a first run in flight across the upgrade restart) to that session via `session_id`, so its history and the orphan sweep's `-s` fingerprint stay the same; jobs that never ran take the new rule. The cli's `-s` is create-on-missing, so the first fire creates the session with the job's agent; every later fire *resumes the same session
|
|
62
|
+
2. Insert a `cron_runs` row with `status='running'` up front (UI sees it mid-flight), recording the session it runs in (`cron_runs.session_id`); broadcast `cron:run_changed`.
|
|
63
|
+
3. Check `job.workspacePath` exists — fail loudly if the workspace was moved/deleted. Same for the job folder (`job.workingDir`, re-resolved with `resolveWorkingDir`): moved / deleted since the job was saved → `failed` with `folder not found: …`, nothing spawned.
|
|
64
|
+
4. Spawn `halo cli -a <agent> -s <session> -w <workspace> --dir=<folder|.> [--access <level>]`, prompt on **stdin** (avoids Windows argv length limits), with a fixed `[Unattended cron run — nobody will answer questions …]` line stamped ahead of `user_prompt` on every fire (`UNATTENDED_PREAMBLE` in `runner.ts`) — the model can't see the `cron-*` session id, and prompts typed into the admin Cron form carry no such notice, so the runner adds it rather than trusting the prompt author. **Session strategy**: every job runs in one stable session — its own by default, or an existing **root** session picked on the job (`cron_jobs.session_id`, nullable; `CRON_MIGRATIONS` slot 1 in `db/cron-db.ts`). The job's own session id **is the job id** (generated ids are already `cron-<ts>-<rand>`; a custom id without that prefix gets `cron-<id>`) — `effectiveSessionId` in `runner.ts` is the one copy of the rule, and the REST list returns its result as `effectiveSessionId` so the admin never re-derives it. Older builds always prefixed, giving `cron-cron-…`; `CRON_MIGRATIONS` slot 3 (v4) pins every job that ever spawned a cli (`last_run_id` set, or a non-skipped run row — covers a first run in flight across the upgrade restart) to that session via `session_id`, so its history and the orphan sweep's `-s` fingerprint stay the same; jobs that never ran take the new rule. The cli's `-s` is create-on-missing, so the first fire creates the session with the job's agent; every later fire *resumes the same session*, so the conversation accumulates and is reviewable in the admin Sessions tab. A **stateless** job (`cron_jobs.stateless`) skips all of this: every fire creates a fresh session (`perRunSessionId`), recorded on the run row.
|
|
65
|
+
|
|
66
|
+
**Agent lock.** The runner always passes `-a <job agent>`, and the cli refuses `-s <existing>` with a different explicit `-a` (a session's agent never changes). So once a stateful job's session exists, the job's agent is fixed to the session's: the REST create / update routes return `400 session <id> already runs agent <x>; …` when the **merged** row's agent differs from the session's (`sessionAgentConflict`; the skill helper exits with the same message). The check only runs when the body actually changes one of its inputs — agent, session, stateless or workspace, compared as stored values (the admin form resends every field) — so a job saved before the rule, or hand-edited into a mismatch, still takes unrelated edits such as `{enabled:false}`; disabling is always allowed. Such a legacy mismatch is not repaired: each of its runs fails with the cli's refusal on stderr as `failureReason`, until the agent is changed back, the session deleted, or the job switched to stateless (which has no lock). The list GET returns the session's agent as `sessionAgentId` (null: stateless / session not created yet), which the admin form locks the agent field on, or flags with an amber notice when it already differs.
|
|
67
|
+
|
|
68
|
+
**Folder.** `--dir=<folder>` is always passed, as **one** token (a folder starting with `-` would otherwise read as a flag), with `.` for the workspace root, so the cli applies it on create *and* re-points a resumed session in place (`SessionManager.setWorkingDir`) — changing a job's folder moves its existing session too. The log header records `dir=`.
|
|
69
|
+
|
|
70
|
+
**Access level.** For a session that already exists the runner reads its `access_level` from the workspace db (`readSessionAccessLevel`, null column = `full`) and passes it as `--access`: the cli defaults to `full` and persists any level that differs from the row, so without it one cron fire would permanently promote a `readonly` / `workspace` session. A session the cli is about to create gets no `--access` (full, as before). The log header records `session=` (with `(stateless)`) / `access=`.
|
|
71
|
+
|
|
72
|
+
**Session lookup** (`readSessionRow`, shared by the access level, `sessionAgentId` and the agent lock). It runs per job on every list GET and `cron:job_changed` push, so it never goes through `getWorkspaceDb` (realpath + `ensureWorkspaceHalo` scaffolding per call — and a GET must not scaffold): one read-only better-sqlite3 handle per workspace path, with the rows cached and dropped whenever `PRAGMA data_version` moves. The handle never writes, so any commit by anyone — the cli child creating the session (`finalize` reads it right after), a Sessions-tab delete, another process — moves the version. **Absent vs unreadable**: no `.halo/halo.db` or no row → null ("no session yet"); any other failure (corrupt / locked db) throws `could not read session <id> from <ws>: …`, so a read error never passes for "no session" — which would spawn a restricted session at `full`, or let the agent lock through. At fire time the run is finalized `failed` with that message and nothing is spawned; the REST write points return it as the `400` (the skill helper reads an unreadable db as no session — the fire still refuses); display (GET `sessionAgentId`, `cron:job_changed`) reads it as null.
|
|
73
|
+
|
|
74
|
+
The child's pid is written back onto the `running` row (`cron_runs.pid`, nullable) — this is what makes the orphan sweep possible after a server crash. Note the **dual-writer layout** this creates: the cli child and the server share the workspace's session files. The server side is view-only for these sessions — admin subscribes seed a UIState from disk, and the WS detach-save skips it via the dirty gate (see [session.md](session.md#ws-disconnect-resilience)); without that gate a frozen server snapshot would overwrite the cli child's newer messages (the cron-session UI-log truncation incident). Live streaming of a cli-driven session into the admin is out of scope — events don't cross processes; the Sessions tab re-reads the file on open.
|
|
62
75
|
|
|
63
76
|
A **picked** session breaks the view-only assumption: the server may hold a live in-memory copy (it was chatted in / opened before, and `ensureSession` keeps it resident), and its raw history predates the cli's turn — the next server-side save would write the cron turn away. So once the cli exits the runner re-`peek`s the workspace's manager (it may have been built mid-run) and calls `SessionManager.forgetExternalWrite(sessionId)`: if the tree is idle, it drops the root + `root>…` sessions from `sessions` **without saving** and drops the UIState, so the next view / message re-reads disk. If a turn started in the session while the cli ran (step 1 only covers turns already running at spawn), it returns false and nothing is dropped — both processes wrote the same files, the last save wins, and the runner logs a `console.warn` that one of the two turns may be missing. That window is the accepted cost of sharing a session with a job; the admin form hint and the `cron` skill both say so.
|
|
64
77
|
5. Tee stdout + stderr live to `~/.halo/global/logs/cron/<runId>.log`; capture stdout to memory for dispatch. Since 1.1.5 the cli's stdout is the **final reply of the last root turn** as raw markdown (stdout is not a TTY, so no terminal styling; the interim text the model emits before tool calls and the wrap-ups of earlier drained turns are not included — see [session.md](session.md#message-queue-and-drain) "Final-text flag"). Before that it was every streamed text block of the whole run, which for a director session absorbing a dozen sub-agent reports reached ~40 KB and blew the wechat gateway ceiling.
|
|
65
78
|
6. Enforce a **per-job timeout** (`job.timeoutSec ?? 3600`, the nullable `cron_jobs.timeout_sec` validated 60–21600 at every write surface; a change takes effect on the job's next fire, no croner re-instantiation needed) via a Node timer (cross-platform, not the Linux-only `timeout(1)`), with a **two-phase kill**: SIGTERM the direct child first — the cli has a SIGTERM handler that stops the harness, repairs + flushes the session, kills its own shell_exec process groups, and exits 143 — then, if it's still alive after a 30s grace, SIGKILL the entire descendant tree (POSIX ppid-walk over one `ps` snapshot; `taskkill /T /F` on Windows) so no grandchild survives to double-write the workspace on the next fire. A ppid walk, not a process-group kill: the cli sandbox spawns shell_exec workers `detached` into their own groups, so `kill(-pid)` would miss them. Timed-out runs still report exit code 124. Generous because overlap-of-the-same-job is already blocked up-front in step 1; this is just the long-stop reaper for a truly stuck child.
|
|
66
79
|
7. Classify: exit 124 → `timeout`; non-zero → `failed`; zero but empty stdout → `failed` ("nothing to dispatch" — with the final-only stdout contract this means the last turn produced no wrap-up text, not that the agent never spoke); else `succeeded`.
|
|
67
80
|
8. On success only, `dispatchToTargets(stdout, targets, job.workspacePath)` — the raw stdout goes down as-is; `MEDIA:` extraction happens per-target inside the dispatcher (see Dispatch model). If every target fails, downgrade to `failed`.
|
|
68
|
-
9. `finalize`: write the terminal `cron_runs` row, update `cron_jobs.lastRun*`, disable one-shot jobs, broadcast. The `_inflight` and `_inflightSessions` entries are released in `finally`, regardless of outcome.
|
|
81
|
+
9. `finalize`: write the terminal `cron_runs` row, update `cron_jobs.lastRun*`, disable one-shot jobs, broadcast. The `cron:job_changed` it sends carries `workingDir` / `stateless` / `sessionAgentId` (`jobChangedFields`, as do the REST mutations' and reconcile's) — `sessionAgentId` read after the run, so a first fire's push already has the agent of the session it just created. The `_inflight` and `_inflightSessions` entries are released in `finally`, regardless of outcome.
|
|
69
82
|
|
|
70
83
|
The cli executable is resolved via `resolveHaloCli()` (`$HALO_CLI` override; `halo.cmd` on Windows to dodge the GUI `Halo.exe` on PATH).
|
|
71
84
|
|
|
@@ -75,8 +88,8 @@ The step-1 guard is per-process, so it has one blind spot: the server dies (SIGK
|
|
|
75
88
|
|
|
76
89
|
`startCronDaemon` calls `sweepOrphanRuns` **before** `reloadAll`. For each stale row:
|
|
77
90
|
|
|
78
|
-
1.
|
|
79
|
-
2. If the row has a pid (POSIX): **identity-check before killing** — `ps -p <pid> -o args=` must carry the session id as a whole argv token (the `-s <session>` value; whole-token so `cron-a` never matches `cron-ab`), so a recycled pid never kills an innocent process. If the job
|
|
91
|
+
1. Take the session the orphan was spawned on from the run row (`cron_runs.session_id` — the only source for a stateless job's per-run id; a row from before that column falls back to the job row's effective session, a job deleted since to the default rule on its id), then re-enter its jobId into `_inflight` and its session key into `_inflightSessions` first — the core invariant: while the orphan may be alive, no new cli for that job *or for any other job on that session* is spawned (a fire in that window is recorded as `skipped`, same as normal overlap).
|
|
92
|
+
2. If the row has a pid (POSIX): **identity-check before killing** — `ps -p <pid> -o args=` must carry the session id as a whole argv token (the `-s <session>` value; whole-token so `cron-a` never matches `cron-ab`), so a recycled pid never kills an innocent process. If the session came from the job row (pre-v5 run row) and was re-pointed after the orphan was spawned, the fingerprint won't match and the orphan is left alone — the safe direction. Verified → SIGTERM (graceful path, same as timeout phase 1); still alive after a 30s async grace (`unref`ed timer, identity re-checked — pid reuse again) → SIGKILL the descendant tree.
|
|
80
93
|
3. No pid / already dead / fingerprint mismatch / Windows: no kill, log only. Windows deliberately marks-only — there's no cheap command-line identity check (WMI is slow and sometimes policy-blocked), and a full-tree `taskkill /T /F` on a reused pid is irreversible while a double-written session is fixable; the orphan-survives-server-death window is also naturally smaller there.
|
|
81
94
|
4. Either way the row is finalized as `failed` (`failureReason` notes the orphan disposition, `completedAt` stamped) so the UI stops showing a ghost `running`, and the jobId / session key are released once the orphan is confirmed dead (or immediately when nothing was killable).
|
|
82
95
|
|
|
@@ -112,7 +125,7 @@ interface CronChannelDispatcher {
|
|
|
112
125
|
|
|
113
126
|
## Key files
|
|
114
127
|
|
|
115
|
-
- `packages/server/src/cron/runner.ts` — scheduler: croner lifecycle, `runJob` spawn/capture/finalize, reconcile, retention
|
|
128
|
+
- `packages/server/src/cron/runner.ts` — scheduler: croner lifecycle, `runJob` spawn/capture/finalize, reconcile, retention; `effectiveSessionId` / `perRunSessionId`, the read-only session lookup (`readSessionRow`) behind `sessionAgentIdFor` / `sessionAgentConflict`.
|
|
116
129
|
- `packages/server/src/cron/dispatcher.ts` — channel-agnostic dispatch registry (`registerCronDispatcher`, `dispatchToTargets`, `listAllCronTargets`).
|
|
117
130
|
- `packages/server/src/db/cron-db.ts` — `cron_jobs` + `cron_runs` schema, `CRON_MIGRATIONS`, `getCronDb`.
|
|
118
131
|
- `packages/server/src/agents/session-manager.ts` — `forgetExternalWrite` (drop the stale in-memory copy of a session a cli child just wrote).
|
|
@@ -121,6 +134,6 @@ interface CronChannelDispatcher {
|
|
|
121
134
|
|
|
122
135
|
## Scope
|
|
123
136
|
|
|
124
|
-
Supported: recurring (cron expr) + one-shot (`runAt`) schedules; per-timezone; optional active window for recurring jobs (`activeFrom` / `activeUntil` — "pause until", "send until"); per-job configurable timeout (`timeoutSec`, 60–21600s, default 3600); multi-target fan-out; stable accumulating session per job — its own (id = job id) or a picked existing root session (`sessionId`); run-now (manual trigger); admin UI history with per-run logs + dispatch results.
|
|
137
|
+
Supported: recurring (cron expr) + one-shot (`runAt`) schedules; per-timezone; optional active window for recurring jobs (`activeFrom` / `activeUntil` — "pause until", "send until"); per-job configurable timeout (`timeoutSec`, 60–21600s, default 3600); multi-target fan-out; stable accumulating session per job — its own (id = job id) or a picked existing root session (`sessionId`) — or a fresh session every run (`stateless`); a per-job folder (`workingDir`, the run's `--dir`); run-now (manual trigger); admin UI history with per-run logs + dispatch results.
|
|
125
138
|
|
|
126
|
-
Not supported: sub-minute schedules below croner's resolution; in-process job execution (always a fresh cli child); running in a sub-session (`>` paths — root sessions only); a conflict-free shared session — a message sent into a picked session while its cron run is in flight can lose one of the two turns (logged, not prevented; see step 4).
|
|
139
|
+
Not supported: sub-minute schedules below croner's resolution; in-process job execution (always a fresh cli child); running in a sub-session (`>` paths — root sessions only); changing a stateful job's agent once its session exists (agent lock); a conflict-free shared session — a message sent into a picked session while its cron run is in flight can lose one of the two turns (logged, not prevented; see step 4).
|
|
@@ -29,7 +29,7 @@ Entry point: `session-agent-builder.ts` `SessionAgentBuilder.buildAgentInstance(
|
|
|
29
29
|
├── USER.md ← optional, overrides global USER.md
|
|
30
30
|
├── INSTRUCTIONS.md ← project-level (overrides global INSTRUCTIONS.md)
|
|
31
31
|
├── INDEX.md ← project overview + doc index
|
|
32
|
-
├── <subdir>/.halo/INSTRUCTIONS.md ← directory-scoped: baked into the system prompt when a sub-agent's
|
|
32
|
+
├── <subdir>/.halo/INSTRUCTIONS.md ← directory-scoped: baked into the system prompt when a session's working_dir (sub-agent, or a root's --dir / cron folder) points here; injected per-turn via user @scope otherwise
|
|
33
33
|
├── prompts/ ← optional, directory-level override of global prompts/
|
|
34
34
|
│ ├── bootstrap/ ← overrides global prompts/bootstrap/ if dir exists
|
|
35
35
|
│ ├── all/ ← overrides global prompts/all/ if dir exists
|
|
@@ -78,7 +78,7 @@ agent's AGENT.md), because the workspace folder replaces the global one whole.
|
|
|
78
78
|
**Override rule.** The workspace-root INSTRUCTIONS.md suppresses the global one (same override as USER.md / AGENT.md), so a cloned workspace is self-contained — its conventions travel with the repo and don't depend on the machine's global file. When the workspace root has no INSTRUCTIONS.md, the global one is used.
|
|
79
79
|
|
|
80
80
|
**Sub-directory INSTRUCTIONS.md — two paths, by who supplies the directory** (see [Directory-scoped instructions](#directory-scoped-instructions-scope) below):
|
|
81
|
-
- **A sub-agent's `
|
|
81
|
+
- **A session's `working_dir`** — a sub-agent's from `start_session`, or a root session's from `halo cli` / `halo tui --dir` or a cron job's folder — is persistent session identity (stored in the DB, restored on resume), so its directory-chain INSTRUCTIONS.md are baked into the **system prompt every turn** — the agent never forgets the rules of the directory it lives in.
|
|
82
82
|
- **A user's `@scope <dir>`** is ad-hoc, so it's injected **per-turn** into that one message only (loop-scoped — relevant to the turn that asked for it, not a permanent part of identity).
|
|
83
83
|
|
|
84
84
|
### INDEX.md (**project root only**)
|
|
@@ -146,7 +146,7 @@ Missing directory or read failure: warn + use built-in fallback.
|
|
|
146
146
|
3. The live agent roster (`## Your Team`), slotted directly behind AGENT.md (see [Agent roster](#agent-roster) below). Empty string for non-delegating agents (no `team` / empty `team`) / internal agents, so the section is dropped.
|
|
147
147
|
4. `## User Instructions` — `~/.halo/global/INSTRUCTIONS.md` (suppressed when the workspace root has its own — see Step 2)
|
|
148
148
|
5. `## User Instructions` — `<ws>/.halo/INSTRUCTIONS.md` (workspace root).
|
|
149
|
-
6. `## User Instructions` —
|
|
149
|
+
6. `## User Instructions` — the session's `working_dir` directory-chain INSTRUCTIONS.md (`loadScopeBody`, plain markdown, headed `### <dir>`; a sub-agent's, or a root's `--dir` / cron folder), folded into the same region right after #4/#5 so the order reads general → specific. Empty when there's no working_dir / it's the root / it has no sub-dir file. (User `@scope` does NOT come through here — it injects per-turn into the message, wrapped in `<workspace-instructions>`.)
|
|
150
150
|
7. `## Project Knowledge` — `<ws>/.halo/INDEX.md` (skipped entirely when no INDEX.md exists)
|
|
151
151
|
|
|
152
152
|
Global and workspace-root INSTRUCTIONS share the same `## User Instructions` heading: they're mutually exclusive (workspace overrides global, see Step 2), so only one ever lands in a prompt and there's no sibling to disambiguate from. A sub-agent's `working_dir` directory-chain (#6) reuses the same heading and is a third, *additive* layer in that region — sub-dir rules add to, never replace, the global/ws-root base, so the order reads general → specific. The roster rides as section 3 — passing it through `composeMdPrompt` rather than concatenating it afterward gives it the same `---` separators as every other section (no glue-to-next-block).
|
|
@@ -163,7 +163,7 @@ mdPrompt ← incl. roster + working_dir
|
|
|
163
163
|
+ allPrompt
|
|
164
164
|
```
|
|
165
165
|
|
|
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.)
|
|
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, present when a root session has a folder — `halo cli` / `halo tui --dir`, a cron job's folder — other than the workspace root.)
|
|
167
167
|
|
|
168
168
|
The roster is computed once (`canDelegate(yaml)` → `buildAgentRoster(agentId, team)`, else `''`, where `canDelegate` is "not internal AND a non-empty `team`") and handed to `composeMdPrompt`, so it lands inside `mdPrompt` behind AGENT.md. Only when there's no AGENT.md at all (the fallback branch) is a non-empty roster appended at the tail instead — there's no MD layer to slot it behind.
|
|
169
169
|
|
|
@@ -194,6 +194,8 @@ If `mdPrompt` is empty:
|
|
|
194
194
|
- Use `yamlConfig.system_prompt` if present
|
|
195
195
|
- Otherwise use the hard-coded default string
|
|
196
196
|
|
|
197
|
+
This branch carries no `working_dir`: no `Working directory:` line, root or sub-agent (`--dir` / cron folder included). The folder's INSTRUCTIONS.md chain is part of `mdPrompt`, so the branch is only reached when that chain is empty too — an agent with a custom `system_prompt` and no AGENT.md whose folder *does* have a chain gets a non-empty `mdPrompt` and the normal branch above, where `system_prompt` is not used.
|
|
198
|
+
|
|
197
199
|
## Agent roster
|
|
198
200
|
|
|
199
201
|
`buildAgentRoster(selfAgentId, team)` ([session-agent-builder.ts](../../../packages/server/src/agents/session-agent-builder.ts)) builds a live team block listing the agents this session can delegate to — one `- \`<id>\` — <name>: <description>` line per teammate.
|
|
@@ -287,6 +289,7 @@ Shared core: [`loadScopeBody(workspaceRoot, relDir)`](../../../packages/server/s
|
|
|
287
289
|
| Trigger | Who | Lifetime | How it reaches the model |
|
|
288
290
|
|---|---|---|---|
|
|
289
291
|
| `working_dir` on `start_session` | parent agent | **persistent** — every turn, for the session's life | `composeMdPrompt` folds the **bare `loadScopeBody` markdown** into the `## User Instructions` region, right after the global/ws-root layer ([`session-agent-builder.composeSystemPrompt`](../../../packages/server/src/agents/session-agent-builder.ts)). No `<workspace-instructions>` wrapper — the section heading already frames it. `working_dir` is stored on `agent_sessions.working_dir` and restored on resume, so the rules are rebuilt into the system prompt on every `buildAgentInstance`. |
|
|
292
|
+
| `--dir` on `halo cli` / `halo tui`, a cron job's folder | user / cron runner | **persistent**, same as above | The root session's `agent_sessions.working_dir`, set on create and re-pointed in place on resume (`SessionManager.setWorkingDir`); same `composeMdPrompt` path. |
|
|
290
293
|
| `@scope <dir>` in a message | user (TUI / admin / channels) | **one-shot** — only the turn it rides on | `loadScopeInstructions` wraps the body in a self-describing `<workspace-instructions dir=... note=...>` block (it lands in the message stream, so it needs the tag + note to read as turn context); `SessionManager.expandScopeMarkers` strips the marker from the visible text and prepends the block to that message. Not re-injected on later turns. |
|
|
291
294
|
|
|
292
295
|
Why the split: `working_dir` is the sub-agent's persistent identity (it lives in that directory for its whole life), so its rules belong in the system prompt where they're present every turn — a first-turn-only injection would let the agent forget them after a few turns. `@scope` is ad-hoc context a user attaches to one message, so it stays loop-scoped. (The earlier `scope` args on `query_session` / `interrupt_session` were removed — they let a parent temporarily swap a child's directory rules, which contradicts working_dir being persistent identity.)
|
|
@@ -383,6 +386,8 @@ Schema declared by each package + values stored centrally is the same model VSCo
|
|
|
383
386
|
## working_dir behaviour
|
|
384
387
|
|
|
385
388
|
- `start_session` accepts a `working_dir` argument (absolute or workspace-relative)
|
|
389
|
+
- A root session takes one too: `halo cli` / `halo tui --dir <path>` (a cron job's folder rides on it), same checks; on a resumed session it re-points the stored value via `SessionManager.setWorkingDir` (`--dir .` = back to the root), refused while the session is running
|
|
390
|
+
- `/session new` (server-side `execNew`) gives the new session the folder of the issuing session's root, re-validated (gone → root)
|
|
386
391
|
- Default: `workspaceRoot`
|
|
387
392
|
- Enforced inside workspace (path traversal is rejected)
|
|
388
393
|
- Non-existent directory → spawn refused
|
|
@@ -394,7 +399,7 @@ Schema declared by each package + values stored centrally is the same model VSCo
|
|
|
394
399
|
|
|
395
400
|
- **Override precedence**: workspace > global (USER.md, AGENT.md, INSTRUCTIONS.md, prompts/)
|
|
396
401
|
- **agents/ & skills/ override**: whole-folder. `<ws>/.halo/agents/<id>/` (or `skills/<id>/`) exists → serves that agent/skill entirely (agent.yaml + AGENT.md, or SKILL.md + resources), global folder ignored, no per-file fallback
|
|
397
|
-
- **INSTRUCTIONS.md**: workspace root INSTRUCTIONS.md suppresses global; subdirectory chain stacks on top — in the system prompt every turn when it's a
|
|
402
|
+
- **INSTRUCTIONS.md**: workspace root INSTRUCTIONS.md suppresses global; subdirectory chain stacks on top — in the system prompt every turn when it's a session's `working_dir` (persistent; sub-agent, or a root's `--dir`), or per-turn in the message via user `@scope` (one-shot)
|
|
398
403
|
- **prompts/ override**: `<ws>/.halo/prompts/<scope>/` directory exists → entirely replaces global's same scope (directory-level, no per-file merge)
|
|
399
404
|
- **INDEX.md is project-root only**: subdirectory INDEX files are not auto-loaded
|
|
400
405
|
- **Root vs sub-agent**: determined by `!parentId`
|
|
@@ -83,6 +83,7 @@ Manages every agent session's lifecycle (root + sub-agent). Each session is 1:1
|
|
|
83
83
|
| Method | Purpose |
|
|
84
84
|
|---|---|
|
|
85
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
|
+
| `setWorkingDir(sessionId, workingDir)` | Re-point a session's persisted `working_dir` (`--dir` on a resumed root session). `workingDir` absolute or null = root; returns `{from, to}` (workspace-relative) or null when it's already the target (no write). Refuses while the session is running; an idle in-memory copy is released so the next `ensureSession` rebuilds the prompt with the new folder |
|
|
86
87
|
| `sendUserMessage(sessionId, text, images?, accessLevel?)` | Send a message — run immediately if idle, queue if busy; returns `'running'` or `'queued'` |
|
|
87
88
|
| `compactSession(sessionId, opts?)` | LLM-summary compact (`opts.onProgress` for UI progress) |
|
|
88
89
|
| `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 |
|
|
@@ -282,7 +283,7 @@ Table `agent_sessions` holds metadata only (no runtime state):
|
|
|
282
283
|
| agent_id | TEXT | Agent YAML ID |
|
|
283
284
|
| agent_name | TEXT | Display name |
|
|
284
285
|
| description | TEXT | Task description |
|
|
285
|
-
| working_dir | TEXT | Workspace-relative path; null = project root |
|
|
286
|
+
| working_dir | TEXT | Workspace-relative path; null = project root. A sub-agent's `working_dir` from `start_session`, or a root session's folder (`halo cli` / `halo tui --dir`, a cron job's folder) — set on create, re-pointed in place on resume by `setWorkingDir` |
|
|
286
287
|
| access_level | TEXT | `'readonly'`, `'workspace'`, or null; null = full access |
|
|
287
288
|
| created_at / updated_at | INTEGER | Epoch ms |
|
|
288
289
|
| stopped_at | INTEGER | null = active |
|
|
@@ -351,6 +352,8 @@ In the admin, `/session new` (also `/clear`, "+ New Session") does not touch the
|
|
|
351
352
|
|
|
352
353
|
Before 1.5.3-alpha this was the WS `session:clear` frame (save, release the listener, unbind the connection); it was removed when one connection started carrying every open tab. Other channels' `/session new` still creates the session server-side and returns `switchTo`.
|
|
353
354
|
|
|
355
|
+
That server-side `execNew` (`channels/shared/commands.ts`) keeps the folder: the new session inherits the `working_dir` of the **root** of the session the command was issued in — `ctx.sessionId` when the caller passes it (the TUI harness does, so a sub-agent tab resolves to its root), else the channel's active session (`findActiveSessionId`). The folder is re-validated with `resolveWorkingDir`; one that no longer exists starts the new session at the workspace root, with a `[Commands] /new: folder of <root> not kept, …` log line.
|
|
356
|
+
|
|
354
357
|
See [background-dispatch.md](background-dispatch.md).
|
|
355
358
|
|
|
356
359
|
## WS disconnect resilience
|
|
@@ -26,7 +26,7 @@ Defines the persisted-data format for every Halo surface. Format changes must re
|
|
|
26
26
|
│ │ # — global so they don't pollute any workspace's halo.db
|
|
27
27
|
│ │ └── <sessionId>.json
|
|
28
28
|
│ ├── evo.db # Cross-workspace evolution queue (evolution_runs + evolution_applies)
|
|
29
|
-
│ ├── cron.db # Cross-workspace cron jobs + run history (cron_jobs + cron_runs)
|
|
29
|
+
│ ├── cron.db # Cross-workspace cron jobs + run history (cron_jobs + cron_runs; schema in design/cron.md, migrations below)
|
|
30
30
|
│ ├── runs.db # Run ledger: (workspace, session_id) rows for server-driven runs in flight; steady state empty (running_sessions)
|
|
31
31
|
│ ├── a2a.db # A2A: inbound tasks, push configs + outbox, outbound dispatches (see design/a2a.md)
|
|
32
32
|
│ ├── extensions/<id>/ # Installed Canvas preview extensions (halo-extension.json + bundle)
|
|
@@ -222,7 +222,7 @@ type ContentBlockEntry =
|
|
|
222
222
|
|
|
223
223
|
`turnId` uniquely identifies each LLM API call. Content blocks and the corresponding usage message in the same turn share the same turnId.
|
|
224
224
|
|
|
225
|
-
The `usage` event rotates `turnId` on both root and sub-session turn state without flushing the assistant message: the agent loop yields `tool_call`s before `usage` and runs the tools after, so at usage time every tool call of that model call is still pending — a flush there would split thinking/text from the tool_calls into two assistant messages (the pre-fix sub-session behavior). One assistant message therefore spans all model calls of a turn, each call's blocks tagged with its own turnId; it reaches disk at `complete` / `agent_done`, the next `user` or `
|
|
225
|
+
The `usage` event rotates `turnId` on both root and sub-session turn state without flushing the assistant message: the agent loop yields `tool_call`s before `usage` and runs the tools after, so at usage time every tool call of that model call is still pending — a flush there would split thinking/text from the tool_calls into two assistant messages (the pre-fix sub-session behavior). One assistant message therefore spans all model calls of a turn, each call's blocks tagged with its own turnId; it reaches disk at `complete` / `agent_done`, the next `user`, `system` or `error` event, or the interrupt flush. Those three run `flushCompletedAssistantMessage` before logging their row: the content up to the first pending tool call is flushed above the row, and the pending tail stays in the buffer below it. `error` always lands in the root log, a sub-session's error included, so it splits the root's buffer (the admin adds the error frame with no taskId, and `placeAroundStreaming` splits the root bubble the same way). Root and sub-session logs persist by one rule: `persistLog` overwrites the session's own file whole from its UI log, and every save carries the in-flight turn as a temporary assistant message (`createSaveSnapshot`). A sub-session log starts from its own file (`loadSubHistory`), so it always holds the full history; writes are debounced 500ms per session id (tool_call / tool_result / usage / context / user / system / error) and flushed at turn end (root `complete`, sub `agent_done`) and on interrupt.
|
|
226
226
|
|
|
227
227
|
### Render rules
|
|
228
228
|
|
|
@@ -231,6 +231,8 @@ The `usage` event rotates `turnId` on both root and sub-session turn state witho
|
|
|
231
231
|
| Normal | user, assistant, notification | tool_call, tool_result, usage, context, agent_start, agent_done |
|
|
232
232
|
| Debug | user, assistant, notification, context, agent_start, agent_done + inlined usage | tool_call, tool_result (already inlined in the assistant's contentBlocks) |
|
|
233
233
|
|
|
234
|
+
Debug usage badges are grouped by turnId, not by log position: `buildExchanges` (`message-list.tsx`) puts each usage row in the exchange holding the last assistant message with a block of that turnId, and the badge renders after that turnId's last block. A usage row reaches the log when its model call returns, before the call's tools run, so a user row that lands mid-call is logged after the badge while the pending tool_calls are flushed below it. Grouping by position left the badge on the bubble above the user row. A usage row with no matching block falls back to the last assistant message of its own exchange.
|
|
235
|
+
|
|
234
236
|
Assistant rendering priority:
|
|
235
237
|
1. Has `contentBlocks` → render in contentBlocks order
|
|
236
238
|
2. No contentBlocks, has `toolCalls` → tools first, then content (legacy)
|
|
@@ -434,6 +436,8 @@ Paths: `~/.halo/global/models/<providerId>.yaml` (bundled, seeded from `template
|
|
|
434
436
|
|
|
435
437
|
**Merge rule** (one implementation, `packages/server/src/models/registry.ts` `loadProviders`, behind the server registry, the Settings secrets forms, `halo setup` and the hub installer): per provider `id` the copy with the higher `revision` wins; on a tie the `models.d` copy wins; a yaml without `revision` counts as 0 (users' own custom yamls in `models/`). A `models.d` copy whose `runtime:` this build doesn't know is skipped with a `[Config]` log, so the bundled copy stays in effect. `halo setup` merges the package's `templates/models/` with `models.d/` the same way.
|
|
436
438
|
|
|
439
|
+
**Hub install** (`halo models install <dir> [--yes]`, `models/install.ts`): plan, then write `models.d/<id>.yaml` via tmp + rename. A yaml is refused (others still go ahead) for an unknown `runtime:` ("needs a newer halo"), a missing / non-integer `revision`, a `secrets[].default` other than empty / `<<ENV_NAME>>`, or bad model fields — `contextWindow` / `maxContextWindow` not positive integers, `contextWindow` > `maxContextWindow`, `compressAt` outside (0, 1], `endpoints` not a list of `https://` URLs (reason shaped `models.<id>.<field> …`). An older revision than the copy in effect is skipped, an identical one is up to date. **Consent**: the whole run writes nothing and needs `--yes` (exit 3) when an installed provider is new, its `defaultEndpoint` / `endpointPresets` change, or a model's `endpoints:` gains a URL the provider didn't use anywhere before (not in its old `defaultEndpoint`, presets or any model's list). Removed URLs, and added ones the provider already used, are listed but need no consent.
|
|
440
|
+
|
|
437
441
|
**Live reload**: `models/watcher.ts` watches `models.d/` (non-recursive, 300 ms debounce; created at boot), drops the registry cache and broadcasts `models:changed` when the merged view changed; admin pages re-fetch `/agent-configs/models` and the settings schema. `models/` isn't watched — only template refresh writes it, before the registry is first read.
|
|
438
442
|
|
|
439
443
|
```yaml
|
|
@@ -445,11 +449,11 @@ displayName: AWS Bedrock Claude (Invoke API) # shown in UI
|
|
|
445
449
|
displayName_zh: AWS Bedrock Claude(Invoke API) # optional zh override
|
|
446
450
|
description: Invokes Bedrock via InvokeModel (non-streaming)
|
|
447
451
|
description_zh: 通过 Bedrock InvokeModel(非流式)调用
|
|
448
|
-
defaultEndpoint: https://bedrock-runtime.us-
|
|
452
|
+
defaultEndpoint: https://bedrock-runtime.us-east-1.amazonaws.com
|
|
449
453
|
defaultModelId: global.anthropic.claude-sonnet-4-6 # picked when user switches to this provider
|
|
450
|
-
endpointPresets:
|
|
451
|
-
- https://bedrock-runtime.us-west-2.amazonaws.com
|
|
454
|
+
endpointPresets: # endpoint dropdown for models without their own `endpoints:`
|
|
452
455
|
- https://bedrock-runtime.us-east-1.amazonaws.com
|
|
456
|
+
- https://bedrock-runtime.us-west-2.amazonaws.com
|
|
453
457
|
|
|
454
458
|
# Server-side credentials. Stored at <provider-id>.secrets.<key> in
|
|
455
459
|
# settings.yaml; never injected into agent prompts or shell_exec.
|
|
@@ -466,7 +470,11 @@ secrets:
|
|
|
466
470
|
models:
|
|
467
471
|
- id: global.anthropic.claude-sonnet-4-6
|
|
468
472
|
displayName: Claude Sonnet 4.6
|
|
473
|
+
# endpoints: [https://…us-east-1…] # optional — only when the model is served on a subset; narrows endpointPresets
|
|
469
474
|
maxOutputTokens: 64000
|
|
475
|
+
contextWindow: 272000 # recommended window for a new agent (≤ 272000, ≤ maxContextWindow)
|
|
476
|
+
maxContextWindow: 1000000 # the model's true limit; absent → contextWindow
|
|
477
|
+
compressAt: 0.9 # compaction ratio for a new agent, (0, 1]
|
|
470
478
|
capabilities:
|
|
471
479
|
image: true # supports image input
|
|
472
480
|
video: false
|
|
@@ -486,18 +494,33 @@ models:
|
|
|
486
494
|
- { value: medium, label: Medium }
|
|
487
495
|
- { value: high, label: High }
|
|
488
496
|
- { value: max, label: Max }
|
|
497
|
+
verbosity: # Responses-API providers only (bedrock-mantle runtime)
|
|
498
|
+
default: medium
|
|
499
|
+
presets:
|
|
500
|
+
- { value: low, label: Low }
|
|
501
|
+
- { value: medium, label: Medium }
|
|
502
|
+
- { value: high, label: High }
|
|
489
503
|
```
|
|
490
504
|
|
|
505
|
+
**Per-model `endpoints:`** — `resolveModelEndpoints(provider, model)` (`models/model-endpoints.ts`; the admin form keeps a byte-identical copy in `features/agents/model-endpoints.ts`, pinned by `test/model-endpoints.test.ts`): a non-empty list is authoritative for that model — it replaces `endpointPresets` in the dropdown, and the model's default endpoint is `defaultEndpoint` when the list contains it, else the list's first entry. No list → provider presets and `defaultEndpoint`. Used by scaffolding / `halo setup` (`modelBlockFromProvider`) and the agent form. Runtime never falls back: a session needs `model.endpoint` in agent.yaml.
|
|
506
|
+
|
|
507
|
+
**Context fields** (`resolveModelContext` / `resolveEffectiveContext`, `config.ts`): `contextWindow` is the *recommended* window, kept ≤ 272000 even for 1M-class models (input past 272K bills at long-context rates on most of them); `maxContextWindow` is the true limit (absent → `contextWindow`); `compressAt` is the model's compaction ratio. At runtime a session's budget is agent.yaml `context.maxTokens` / `compressAt`, else the global defaults — `config.model.maxContextTokens` (272000) capped at the model's max, and `general.compact.compress_at` (0.9) — then `maxTokens` is clamped to the model's max (warned, `[SessionAgentBuilder] … clamped`). Same resolution for live sessions (`buildModelRuntime`) and cold ones (`getContextConfig`). `PUT /agent-configs/:id/yaml` refuses a new `context.maxTokens` above the model's max (400); an over-max value already on disk for the same model stays editable.
|
|
508
|
+
|
|
509
|
+
**Scaffold-only defaults** — the registry's recommended `contextWindow` / `compressAt` and `capabilities.verbosity.default` are written into agent.yaml when an agent is created (new-agent scaffold, `halo setup` rebind, the form's model / provider switch); runtime never reads them for an existing agent, so a registry update can't move a running agent. A new agent's `context:` = `contextWindow` (else the runtime default) and compressAt = explicitly set global `compact.compress_at` > model `compressAt` > 0.9 (`explicitCompressAt()`, `agent-configs.ts` `scaffoldContext`; settings.yaml only holds keys a user wrote, so absent = never set). Verbosity at runtime comes only from agent.yaml `model.verbosity`; absent → MantleAgent sends `low`.
|
|
510
|
+
|
|
491
511
|
### Capability defaults — what each `default*` field controls
|
|
492
512
|
|
|
493
513
|
| Path | Used when | Fallback |
|
|
494
514
|
|---|---|---|
|
|
495
515
|
| `<provider>.defaultModelId` | User switches provider; form picks this model | `models[0].id` |
|
|
496
|
-
| `<provider>.defaultEndpoint` | Same; endpoint input | `''` |
|
|
516
|
+
| `<provider>.defaultEndpoint` | Same; endpoint input (narrowed by the model's `endpoints:`, see above) | `''` |
|
|
517
|
+
| `models[].contextWindow` | New agent / model switch with an untouched or over-max window → `context.maxTokens` | runtime default (272000 capped at max) |
|
|
518
|
+
| `models[].compressAt` | New agent (unless the global `compress_at` is set) / model switch with an untouched ratio → `context.compressAt` | global `compress_at`, 0.9 |
|
|
497
519
|
| `capabilities.promptCaching.default` | Toggle on / provider switch into a caching-capable model | `ttlPresets[0].value` |
|
|
498
520
|
| `capabilities.thinking.defaultEnabled` | New agent or provider switch with no prior thinking config | `false` |
|
|
499
521
|
| `capabilities.thinking.default` | Thinking toggled on, adaptive mode | `effortPresets[0].value` |
|
|
500
522
|
| `capabilities.thinking.defaultBudgetTokens` | Thinking toggled on, manual mode | `8192` |
|
|
523
|
+
| `capabilities.verbosity.default` | New agent / provider or model switch with no valid prior value → `model.verbosity` | none written; runtime `low` |
|
|
501
524
|
|
|
502
525
|
Existing agent yaml values always win over these defaults — switching providers carries the user's effort/ttl over when valid for the new model. The defaults only fire when there's nothing to carry, or the carry-over isn't valid in the new vocabulary (e.g. Bedrock `medium` → Kimi `enabled/disabled` mismatch resets to the new provider's `default`).
|
|
503
526
|
|
|
@@ -505,8 +528,8 @@ Existing agent yaml values always win over these defaults — switching provider
|
|
|
505
528
|
- `image` / `video` / `audio` (boolean) → modality badges in the form; at runtime, unsupported modality inputs are filtered out with a text notice instead of causing API errors
|
|
506
529
|
- `promptCaching.ttlPresets` → Prompt Caching dropdown
|
|
507
530
|
- `thinking.mode`:
|
|
508
|
-
- `adaptive` → wire format `thinking: {type: 'adaptive'}` + `output_config.effort` (Sonnet 4.6+ / Opus 4.6 and later, incl. Opus 5)
|
|
509
|
-
- `manual` → wire format `thinking: {type: 'enabled', budget_tokens: N}` (Haiku 4.5 and other legacy thinking models). UI offers a budget number input; runtime translates effort labels via
|
|
531
|
+
- `adaptive` → wire format `thinking: {type: 'adaptive'}` + `output_config.effort` (Sonnet 4.6+ / Opus 4.6 and later, incl. Opus 5). Qwen 3.8 Max / Flash use the same `output_config.effort` with `thinking: {type: 'enabled'}`, unless the agent.yaml carries an explicit `budget_tokens`.
|
|
532
|
+
- `manual` → wire format `thinking: {type: 'enabled', budget_tokens: N}` (Haiku 4.5 and other legacy thinking models). UI offers a budget number input; runtime translates effort labels via `effortToBudget` in `anthropic-request.ts` if needed.
|
|
510
533
|
- `thinking.effortPresets` → Thinking effort dropdown (when `mode: adaptive`)
|
|
511
534
|
- Omit a block → the UI hides that control for this model
|
|
512
535
|
|
|
@@ -565,7 +588,7 @@ interface SkillMeta {
|
|
|
565
588
|
}
|
|
566
589
|
```
|
|
567
590
|
|
|
568
|
-
Load precedence
|
|
591
|
+
Load precedence: workspace > global > extension, **whole-folder override** (the agent rule, plus a third, read-only source: `~/.halo/global/extensions/<extId>/skills/<id>/`, see [canvas-extensions.md → Extension skills](canvas-extensions.md#extension-skills)). A higher `skills/<id>/` folder replaces the lower skill wholesale — SKILL.md plus every sibling resource file — with no per-file fallback.
|
|
569
592
|
|
|
570
593
|
## SQLite databases
|
|
571
594
|
|
|
@@ -607,7 +630,7 @@ The last four are a **read cache for the session list** — `GET /api/sessions/l
|
|
|
607
630
|
|---|---|---|
|
|
608
631
|
| item_type | TEXT NOT NULL | `'agent'` or `'skill'` |
|
|
609
632
|
| item_id | TEXT NOT NULL | Agent/skill ID |
|
|
610
|
-
| scope | TEXT NOT NULL | `'global'` or `'
|
|
633
|
+
| scope | TEXT NOT NULL | `'global'`, `'workspace'` or `'extension'` (a skill shipped by an installed extension, `agents/skill-sources.ts`) — which source the item comes from. `'extension'` added by `HALO_MIGRATIONS` v2 (table rebuild, rows kept) |
|
|
611
634
|
| disabled_at | INTEGER NOT NULL | Unix ms |
|
|
612
635
|
| | | **PK**: `(item_type, item_id, scope)` |
|
|
613
636
|
|
|
@@ -642,7 +665,8 @@ All channel types (telegram, web, wechat, slack, feishu, wecom) share one table.
|
|
|
642
665
|
|
|
643
666
|
- `schema.sql` / each db's `CREATE_SQL` always describes the FULL current shape, so a fresh db is complete after the CREATEs alone
|
|
644
667
|
- Every change to an already-existing db gets a numbered slot in that file's ordered migration list — `HALO_MIGRATIONS` (`db/index.ts`), `CRON_MIGRATIONS` (`db/cron-db.ts`), `A2A_MIGRATIONS` (`db/a2a-db.ts`); `channel-db.ts` / `evo-db.ts` / `runs-db.ts` currently pass an empty list. `runMigrations(sqlite, list)` (`db/migrate.ts`) runs `list[user_version..]` in order, each in its own transaction, stamping `PRAGMA user_version = i + 1` after each
|
|
645
|
-
- A fresh db is also at `user_version 0` and runs the whole list, so every slot must be a no-op against the current shape (`addColumnIfMissing`, `CREATE … IF NOT EXISTS`)
|
|
668
|
+
- A fresh db is also at `user_version 0` and runs the whole list, so every slot must be a no-op against the current shape (`addColumnIfMissing`, `CREATE … IF NOT EXISTS`). A CHECK constraint can't be altered in SQLite: the slot rebuilds the table (create `_v2`, copy rows, drop, rename) and skips when `sqlite_master` already shows the new constraint — `HALO_MIGRATIONS` v2 (`disabled_items.scope` admits `'extension'`) is the pattern
|
|
669
|
+
- Current slots worth knowing: `HALO_MIGRATIONS` v2 — the `disabled_items` rebuild above (rows kept). `CRON_MIGRATIONS` v5 — `cron_jobs.working_dir TEXT NULL` (job folder → `halo cli --dir`), `cron_jobs.stateless INTEGER NOT NULL DEFAULT 0` (1 = fresh session every run), `cron_runs.session_id TEXT NULL` (the session that run used); column semantics in [cron.md](cron.md)
|
|
646
670
|
- Append only — never reorder or edit a shipped slot. A db opened by a newer halo (user_version > list length) logs a warning and continues
|
|
647
671
|
- Do not drop or rename existing columns
|
|
648
672
|
- New channel types: add rows with a new `channel_type` value + define config shape in the channel's `accounts.ts` adapter
|
|
@@ -660,3 +684,4 @@ All channel types (telegram, web, wechat, slack, feishu, wecom) share one table.
|
|
|
660
684
|
| v1+ | 2026-08-08 | Session file slimming (no version bump — additive + reader-compatible): assistant `toolCalls` no longer written (`contentBlocks` is the single copy, `toolCalls` stays read-only legacy); files written as compact JSON; UI-log archiving adds header fields `archiveCount` / `archivedUserCount` plus `{sessionId}.arch.{N}.json.gz` segment files; `agent_sessions` gains mirrored `title` / `exchange_count` / `context_tokens` / `total_output_tokens` columns so listing doesn't parse every file. |
|
|
661
685
|
| v1+ | 2026-09-18 | Relay + liveness (additive, no version bump): `agent_sessions.reply_to` column (idempotent ALTER); session file gains `lastActivityAt` beside `output`, read back by `get_session_output` for released sessions. |
|
|
662
686
|
| v1+ | 2026-09-20 | Schema migrations moved from ad-hoc boot-time ALTERs to `PRAGMA user_version` + ordered per-db migration lists (`db/migrate.ts`); existing dbs pick up at slot 0 and every slot is idempotent. Session-message + WS frame types moved to `@turmind/halo-core/protocol` (re-exported from their old server paths; no wire change). |
|
|
687
|
+
| v1+ | 2026-10-10 | Model registry (additive): per-model `endpoints:`, `maxContextWindow`, `compressAt`; recommended `contextWindow` ≤ 272000; registry context / compressAt / verbosity defaults are scaffold-only; installer validates model fields and asks consent for URLs new to a provider. `HALO_MIGRATIONS` v2: `disabled_items.scope` admits `'extension'`. `CRON_MIGRATIONS` v5: `cron_jobs.working_dir`, `cron_jobs.stateless`, `cron_runs.session_id`. |
|
|
@@ -92,10 +92,10 @@ Source: [event-processor.ts](../../../packages/server/src/ws/event-processor.ts)
|
|
|
92
92
|
|
|
93
93
|
| Agent event | WS type | Fields |
|
|
94
94
|
|---|---|---|
|
|
95
|
-
| `thinking_delta` | `chat:thinking` | text, agentName,
|
|
96
|
-
| `stream_delta` | `chat:stream` | text, agentName,
|
|
97
|
-
| `thinking` | `chat:thinking` | text, agentName,
|
|
98
|
-
| `stream` | `chat:stream` | text, agentName,
|
|
95
|
+
| `thinking_delta` (no taskId) | `chat:thinking` | text, agentName, turnId, sessionId |
|
|
96
|
+
| `stream_delta` (no taskId) | `chat:stream` | text, agentName, turnId, sessionId |
|
|
97
|
+
| `thinking` (no taskId) | `chat:thinking` | text, agentName, turnId, sessionId — **not forwarded** when `streamed: true` |
|
|
98
|
+
| `stream` (no taskId) | `chat:stream` | text, agentName, turnId, sessionId — **not forwarded** when `streamed: true` |
|
|
99
99
|
| `agent_start` | `agent:start` | agentName, task, taskId, sessionId |
|
|
100
100
|
| `agent_done` | `agent:done` | agentName, taskId, sessionId |
|
|
101
101
|
| `tool_call` | `agent:tool_call` | tool, toolUseId, input, agentName, taskId, turnId, sessionId |
|
|
@@ -110,6 +110,8 @@ Source: [event-processor.ts](../../../packages/server/src/ws/event-processor.ts)
|
|
|
110
110
|
|
|
111
111
|
Every 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): they emit `stream_delta` / `thinking_delta` per chunk *during* the model call, and the whole `stream` / `thinking` that follows the call is stamped `streamed: true` — the wire frame is the same `chat:stream` / `chat:thinking` either way (the admin appends by `turnId`), so the whole event is dropped to avoid rendering the text twice. A provider that never calls `onDelta` would still send the whole event only, exactly as before streaming existed.
|
|
112
112
|
|
|
113
|
+
Sub-agent text is root-tree noise on this wire: the listener is the root's (sub ids normalize to it), every admin view filters `taskId` rows out, and the Sessions tab reads a sub-session's log from disk. So the four text events above go out **root only**. A sub-agent's `stream*` / `thinking*` produce no frame, while its `agent:start` / `agent:done`, tool frames, `agent:context`, `chat:system` and `error` still carry `taskId`. Web SSE (and ACP on top of it), the IM channels and A2A register their own event listeners rather than reading these frames, so they are unaffected. Two harmless side effects in the admin. A text-only sub-agent leaves its empty `agent:start` placeholder in the root store, and after a link drop `convergeStaleStreaming` may mark it interrupted; every view filters that row out anyway. A background tab's unread dot no longer lights for sub-agent text alone, though the sub-agent's tool and `agent:*` frames still light it.
|
|
114
|
+
|
|
113
115
|
`sessionId` is the session the emitting listener belongs to. A connection holds one listener per subscribed session (`createEventListener(client, sessionId)`), and each listener is bound to its id when it is created. Since 1.5.3-alpha the session-scoped frames sent outside the event mapping carry it too: `compact:started` / `compact:summarizing` / `compact:done`, `session:compacted`, `chat:queued`, `chat:system` / `error` replies to a command or chat, the detached-buffer replays (`agent:start` / `agent:done` / `error` / `chat:followup` from `bufferDetachedNotification`), and `chat:usage` on subscribe.
|
|
114
116
|
|
|
115
117
|
**Admin routing** (`storeForFrame` in `chat-tabs.ts`, used by `chat-handlers.ts` / `agent-handlers.ts` / `state-handlers.ts`): a frame with a `sessionId` goes to the store of the loaded tab holding that session, background tabs included. A frame landing in a background tab lights its unread dot; state flips (snapshot, compact progress) don't. A frame for a session no tab has loaded (never opened, or released by a reconnect) is dropped. A frame without a `sessionId` (connection-level replies) goes to the tab on screen. `<<<CAPTURE>>>` / `<<<SHOW>>>` markers fire on `chat:complete` only for the tab on screen — a background session must not grab a screen frame or drive the face the user is looking at.
|
|
@@ -138,7 +140,7 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
|
|
|
138
140
|
| `chat:stopped` | `chat:stop` / `chat:interrupt` handlers (this client only) | Stop/interrupt acknowledged — `{sessionId}` |
|
|
139
141
|
| `session:compacted` | compact handler | Compaction complete — `{message?, contextTokens, sessionId}` |
|
|
140
142
|
| `compact:started` / `compact:summarizing` / `compact:done` | compact handler; `ws/event-processor.ts` | Compaction progress, `{sessionId}`. An auto-compact whose LLM summary failed falls back to a local compact and closes with a `system` event carrying `compactEnd`, which also emits `compact:done` (the success path closes via `compacted`). |
|
|
141
|
-
| `cron:job_changed` / `cron:run_changed` | `cron/runner.ts` + `routes/cron.ts` (broadcast) | Cron job/run state changed — the Cron tab re-fetches instead of polling. See [cron.md](cron.md). |
|
|
143
|
+
| `cron:job_changed` / `cron:run_changed` | `cron/runner.ts` + `routes/cron.ts` (broadcast) | Cron job/run state changed — the Cron tab re-fetches instead of polling. `cron:job_changed`: `{jobId, kind?}` (`created` / `updated` / `deleted` / `reconciled` / `missed`; a run's finalize sends `lastRunStatus` + `lastRunAt` instead), plus — whenever the job still exists, except `missed` — `workingDir` (workspace-relative, `null` = root), `stateless` (boolean) and `sessionAgentId` (agent of the job's existing session, `null` when stateless / not created / unreadable; the job's agent is locked to it). `cron:run_changed`: `{jobId, runId, status}`. See [cron.md](cron.md). |
|
|
142
144
|
| `evolution:run_changed` / `evolution:apply_changed` | `evolution/ticker.ts` + `routes/evolution.ts` (broadcast) | Evolution run/apply state changed — drives the Evolution tab. See [evolution.md](evolution.md). |
|
|
143
145
|
| `models:changed` | `models/watcher.ts` `reloadAndBroadcast` (broadcast to all clients) | `~/.halo/global/models.d/` changed (hub provider yamls installed by `halo models install` / `/extension models`, or removed by hand) and the merged registry differs from the last one — the server has already dropped its registry cache. No payload: the admin bumps its models bus, and the Agents panel / agent form, the composer's image-capability check and the Settings page re-fetch `/agent-configs/models` / the settings schema. Also bumped on WS reconnect (a frame lost while down). Global, no workspace marker. See [storage.md](storage.md#model-registry-format). |
|
|
144
146
|
| `extension:changed` | `extensions/watcher.ts` `rescanAndBroadcast` (broadcast to all clients) | `~/.halo/global/extensions/` changed (install / upgrade / uninstall, from the admin upload, the `extension` skill, or a manual `mv`/`rm`) — carries the **full** `ExtensionsSnapshot` (`{extensions, errors}`), not a diff, and only fires when the snapshot key differs from the last one. The admin replaces its extension list wholesale: the preview registry re-resolves open tabs (an upgraded extension remounts, or shows a banner if the tab is dirty; an uninstalled one falls back to the "no built-in preview" page) and the Settings → Extensions panel re-renders. Global, no workspace marker — extensions are server-wide. See [canvas-extensions.md](canvas-extensions.md). |
|
|
@@ -24,16 +24,16 @@ Runtime names (`runtime:` value → class): `bedrock-invoke` → `BedrockAgent`,
|
|
|
24
24
|
|---|---|---|---|---|
|
|
25
25
|
| `aws-bedrock-claude-invoke` | `bedrock-invoke` | `BedrockAgent` | `bedrock-agent.ts` | Anthropic Messages API via AWS SDK |
|
|
26
26
|
| `kimi` | `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 |
|
|
27
|
-
| `deepseek` | `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 |
|
|
27
|
+
| `deepseek` | `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). Thinking is on by default, so off is sent explicitly as `thinking:{type:'disabled'}`. On, it sends `reasoning_effort` with the label unmapped: presets are the API's own low / high / max, and the API folds medium / xhigh to high itself, so older agents keep working. 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 |
|
|
28
28
|
| `minimax` | `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) |
|
|
29
|
-
| `qwen` | `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,
|
|
30
|
-
| `hunyuan` | `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 (
|
|
31
|
-
| `doubao` | `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,
|
|
29
|
+
| `qwen` | `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; omitting the field means thinking on, so off is always sent as `type:'disabled'`. Two thinking schemes, branched on registry `thinking.mode`: **3.8 Max / Flash** (`adaptive`) send `thinking:{type:'enabled'}` + `output_config.effort` (low / medium / xhigh; the API folds high / max onto xhigh). **3.7 / 3.6** (`manual`) send `budget_tokens` via `effortToBudget`. An agent.yaml with an explicit `budget_tokens` keeps sending it on either. Prompt caching (5m only), vision on Plus, max no vision |
|
|
30
|
+
| `hunyuan` | `hunyuan` | `HunyuanAgent` | `hunyuan-agent.ts` | Tencent Hy3 / Hy4 preview / 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, thinking off sends `reasoning_effort: 'none'` (an omitted field thinks by default — live-probed 2026-10-10), automatic prompt caching (cached_tokens), no vision |
|
|
31
|
+
| `doubao` | `doubao` | `DoubaoAgent` | `doubao-agent.ts` | ByteDance Doubao Seed 2.1 Pro + 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. Images go out as `image_url` content parts; only 2.1 Pro is `image: true` (the 2.0 models misread a red PNG), so the registry flag gates the rest |
|
|
32
32
|
| `zhipu` | `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). |
|
|
33
|
-
| `openai` | `openai-chat` | `OpenAIAgent` | `openai-agent.ts` | **Generic OpenAI-compatible**. Use for native OpenAI or any third-party OpenAI gateway (incl. Ollama / llama.cpp).
|
|
34
|
-
| `anthropic` | `anthropic-messages` | `AnthropicAgent` | `anthropic-agent.ts` | **Generic Anthropic Messages**. Use for api.anthropic.com or any Anthropic-compatible gateway.
|
|
33
|
+
| `openai` | `openai-chat` | `OpenAIAgent` | `openai-agent.ts` | **Generic OpenAI-compatible**. Use for native OpenAI or any third-party OpenAI gateway (incl. Ollama / llama.cpp). Ships a short Bedrock Mantle list callable with a Bedrock API key (`openai.gpt-5.5` default, `openai.gpt-5.4`; `defaultEndpoint` Mantle us-east-1 `/openai/v1`, presets us-east-1 / us-west-2 / api.openai.com — GPT-6.x / 5.6 reject `max_tokens`, so they stay on the Responses-API providers) plus the legacy `gpt-4o` placeholder (official API only); any other endpoint + model id can be typed, and capabilities for unlisted ids are 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). |
|
|
34
|
+
| `anthropic` | `anthropic-messages` | `AnthropicAgent` | `anthropic-agent.ts` | **Generic Anthropic Messages**. Use for api.anthropic.com or any Anthropic-compatible gateway. Ships a short Bedrock Mantle list callable with a Bedrock API key as `x-api-key` (`anthropic.claude-opus-5-5` default, `anthropic.claude-haiku-4-5`; `defaultEndpoint` Mantle us-east-1 `/anthropic`, presets us-east-1 / us-west-2 / api.anthropic.com) plus the legacy `claude-sonnet-4-6` placeholder (official API only); any other endpoint + model id can be typed. Streaming via `fetchAnthropicStream` (the gateway must support `stream: true` SSE — the reference Anthropic behaviour). Standard `cache_control: ephemeral` with optional `ttl: '1h'`. |
|
|
35
35
|
| `mimo-token-plan-china` | `anthropic-messages` | `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 `runtime: anthropic-messages` in its yaml (`defaultEndpoint` `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 |
|
|
36
|
-
| `aws-bedrock-mantle` | `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
|
|
36
|
+
| `aws-bedrock-mantle` | `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` (us-east-1 / us-west-2 / us-east-2, default us-east-1, `openai.gpt-6-sol` default model) and pins each model's regions with per-model `endpoints:` (Astra us-west-2; GPT-6.1 Sol / GPT-6 Sol / Luna us-east-1; GPT-5.6 Sol us-east-1 + us-east-2; Terra / Luna none = all presets), so the form only offers — and on a model switch moves to — a region that serves the model; `extractRegionFromHost` falls back to us-east-1. GPT-6 Astra / Sol / Luna: 1,050,000 context / 128,000 max output; GPT-6.1 Sol 1M / 131,072 (card; Mantle accepts any `max_output_tokens` ≥ 16, so the cap isn't enforced server-side); same effort (none/low/medium/high/xhigh/max) + verbosity surface. Input beyond 272K tokens bills at 2x, so every entry ships `contextWindow: 272000` (what a new agent gets) with the 1M-class figure as `maxContextWindow`; raise `context.maxTokens` past 272K only on purpose. 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). |
|
|
37
37
|
| `aws-bedrock-openai` | `bedrock-mantle` | `MantleAgent` | (reuses `mantle-agent.ts`) | Frontier models on Bedrock via the **bedrock-runtime** host's OpenAI Responses surface (`https://bedrock-runtime.<region>.amazonaws.com/openai/v1` — AWS's recommended endpoint for new apps; Mantle is the compatibility one). Same wire format, so its yaml just says `runtime: bedrock-mantle` + `extractRegionFromHost` accepts both hostnames (SigV4 signs with the host's region — a mismatched region 401s "Credential should be scoped to a valid region"). Rules that differ from Mantle: model ids must be cross-Region inference profiles (`global.*` / `us.*`; bare ids 400 "on-demand throughput isn't supported"); **no region split** — live-probed 2026-09-24, all nine `global.*` ids answer 200 from us-east-1 / us-west-2 / eu-west-1 / ap-northeast-1 alike; **no server-side tools** — `tools:[{type:"web_search"}]` 400s "web search is not supported for this request", which is why the web-search skill's deep gear stays on Mantle. Ships the same seven **GPT-6.1 / 6 / 5.6** entries as the Mantle template (`global.openai.gpt-6.1-sol`, `global.openai.gpt-6-{astra,sol,luna}`, `global.openai.gpt-5.6-{sol,terra,luna}`; identical effort none/low/medium/high/xhigh/max + verbosity surface per each model's own validation error — except Astra and GPT-6.1 Sol 400 on `none`, which the runtime never sends), plus **Grok 4.6** (500K ctx, 2x input price past 200K, max output undisclosed → 64000 carry-over, effort **only** low/medium/high/xhigh — `none`/`max` 400, so no Max preset; vision OK; $2/$6) and **Kimi K3** (1M ctx / 131072 out, effort none…max all accepted, ladder advisory on short prompts; vision OK; $3/$15). All: implicit prompt caching verified (run 1 `cache_write_tokens`, runs 2+ `cached_tokens` ≈ prefix), no reasoning summaries. Streams like Mantle (same event vocabulary) but this host **does** append a trailing `data: [DONE]`, which `readSseJson` skips; grok-4.6 emits a `reasoning` output item (no summary text deltas) before the `message` item. |
|
|
38
38
|
|
|
39
39
|
---
|
|
@@ -164,12 +164,17 @@ revision: 2026100501 # required for bundled / hub yamls — in
|
|
|
164
164
|
displayName: My Provider
|
|
165
165
|
description: Short description
|
|
166
166
|
defaultEndpoint: https://api.example.com/v1
|
|
167
|
+
endpointPresets: # optional — the form's endpoint dropdown
|
|
168
|
+
- https://api.example.com/v1
|
|
167
169
|
|
|
168
170
|
models:
|
|
169
171
|
- id: my-model-v1
|
|
170
172
|
displayName: My Model V1
|
|
173
|
+
# endpoints: [https://api.example.com/v1] # optional — only when this model is served on a subset of the presets
|
|
171
174
|
maxOutputTokens: 16384
|
|
172
|
-
contextWindow: 200000 #
|
|
175
|
+
contextWindow: 200000 # recommended window for a new agent: official figure, ≤ 272000
|
|
176
|
+
maxContextWindow: 200000 # the model's true limit (absent → contextWindow)
|
|
177
|
+
compressAt: 0.9 # compaction ratio for a new agent, (0, 1]
|
|
173
178
|
capabilities:
|
|
174
179
|
image: true # supports image input
|
|
175
180
|
video: false # no video understanding
|
|
@@ -185,12 +190,13 @@ models:
|
|
|
185
190
|
|
|
186
191
|
**Capability flags drive the UI.** The Form view reads this file directly to decide which controls to render:
|
|
187
192
|
- `defaultEndpoint` → auto-fills endpoint when switching provider in agent/settings UI
|
|
193
|
+
- `endpointPresets` → the endpoint dropdown; a model's own `endpoints:` replaces it for that model, and the form moves the endpoint onto that list when the user switches to the model (`resolveModelEndpoints`, `models/model-endpoints.ts` + the admin copy)
|
|
188
194
|
- `capabilities.image` / `capabilities.video` / `capabilities.audio` → modality badges (green = supported, grey = unsupported); also controls runtime behavior in two ways: (a) the `view_image` workspace tool is dropped from the agent's tool list at session-create time when `image: false`, so the model never sees a tool the provider would 400 on; (b) user-supplied images on inbound messages are filtered with a text notice instead of being sent to the API
|
|
189
195
|
- `capabilities.promptCaching.ttlPresets` → renders the Prompt Caching dropdown with those options
|
|
190
196
|
- `capabilities.thinking.effortPresets` → renders the Thinking effort dropdown
|
|
191
197
|
- Omit either block → the UI hides that control for this model
|
|
192
198
|
|
|
193
|
-
|
|
199
|
+
**Context fields** — `contextWindow` is the *recommended* window, written into a new agent's `context.maxTokens`: the official figure, capped at 272000 (1M-class models bill input past 272K at long-context rates); `maxContextWindow` is the model's true limit; `compressAt` the new agent's compaction ratio. Read through `resolveModelContext()` / `resolveEffectiveContext()` in [config.ts](../../../packages/server/src/config.ts). At runtime a session's budget (compaction threshold, token ring) is agent.yaml `context.maxTokens` / `compressAt`, else the global defaults (`config.model.maxContextTokens` 272K capped at the model's max, `general.compact.compress_at` 0.9), then clamped to the model's max — the same chain for live sessions (`buildModelRuntime`, session-agent-builder.ts) and cold ones (`getContextConfig()`, session-manager.ts). The registry's recommended `contextWindow` / `compressAt` and `capabilities.verbosity.default` are **scaffold-only**: written into agent.yaml when an agent is created or the form switches model, never read at runtime for an existing agent. A new agent's compressAt is the explicitly set global `compress_at`, else the model's `compressAt`, else 0.9. Declare all three on every model entry; without `maxContextWindow` / `contextWindow` a small-window model (e.g. 128K) runs at 272K and overflows before compaction kicks in. Details: [design/storage.md#model-registry-format](../design/storage.md#model-registry-format).
|
|
194
200
|
|
|
195
201
|
Shape source: [packages/server/templates/models/aws-bedrock-claude-invoke.yaml](../../../packages/server/templates/models/aws-bedrock-claude-invoke.yaml) and [design/storage.md#model-registry-format](../design/storage.md#model-registry-format).
|
|
196
202
|
|
|
@@ -198,7 +204,7 @@ Shape source: [packages/server/templates/models/aws-bedrock-claude-invoke.yaml](
|
|
|
198
204
|
|
|
199
205
|
### Distributing a provider change through the hub
|
|
200
206
|
|
|
201
|
-
A new model, a renamed model id or a changed endpoint doesn't need a halo release: edit the yaml in [halo-hub](https://github.com/turmind/halo-hub) `models/`, bump its `revision`, `node scripts/pack.mjs models`, and publish the zip as release `models-v<YYYY.MM.DD>` (see the hub README). Users run `/extension models` (or say "更新模型列表"); `halo models install` writes the files into `models.d/`, and the server's `models.d` watcher drops the registry cache and pushes `models:changed`, so open admins re-fetch — no restart, no reload. Install-time refusals: a `runtime:` this build doesn't know ("needs a newer halo" — a **new runtime class always needs a halo release**), a missing `revision`, a `secrets[].default` other than empty / `<<ENV_NAME>>`. A new provider
|
|
207
|
+
A new model, a renamed model id or a changed endpoint doesn't need a halo release: edit the yaml in [halo-hub](https://github.com/turmind/halo-hub) `models/`, bump its `revision`, `node scripts/pack.mjs models`, and publish the zip as release `models-v<YYYY.MM.DD>` (see the hub README). Users run `/extension models` (or say "更新模型列表"); `halo models install` writes the files into `models.d/`, and the server's `models.d` watcher drops the registry cache and pushes `models:changed`, so open admins re-fetch — no restart, no reload. Install-time refusals: a `runtime:` this build doesn't know ("needs a newer halo" — a **new runtime class always needs a halo release**), a missing `revision`, a `secrets[].default` other than empty / `<<ENV_NAME>>`. Also refused: model `contextWindow` / `maxContextWindow` that aren't positive integers, `contextWindow` > `maxContextWindow`, `compressAt` outside (0, 1], `endpoints` that isn't a list of `https://` URLs. A new provider, a changed `defaultEndpoint` / `endpointPresets`, or a model `endpoints:` URL the provider didn't use anywhere before is listed and needs the user's explicit OK (`--yes`). Mirror the change in `templates/models/` with the same revision so the next release bundles it.
|
|
202
208
|
|
|
203
209
|
### 3. Wire the dispatcher
|
|
204
210
|
|
|
@@ -289,7 +295,7 @@ If something's wrong, server logs under `~/.halo/global/logs/server.log` are the
|
|
|
289
295
|
|
|
290
296
|
(A curated provider of your own for a stock-protocol vendor is just a manifest with `runtime: openai-chat` or `runtime: anthropic-messages` — `mimo-token-plan-china` is that case.)
|
|
291
297
|
|
|
292
|
-
Two of the providers above are deliberately generic — they take whatever `endpoint` + `model id` the user fills in the form
|
|
298
|
+
Two of the providers above are deliberately generic — they take whatever `endpoint` + `model id` the user fills in the form. Their model list is short on purpose: a few Bedrock Mantle models that work out of the box with a Bedrock API key (the default endpoint is Mantle us-east-1) plus one legacy placeholder, and the models census skips them — new models are tracked through the `aws-bedrock-*` providers. Use them when:
|
|
293
299
|
|
|
294
300
|
- The new vendor speaks **stock OpenAI chat completions** or **stock Anthropic Messages** with no quirks worth coding around. Examples: self-hosted vLLM, third-party OpenAI gateway.
|
|
295
301
|
- You don't want to maintain a model registry for that vendor — users are expected to know what model id and endpoint to use.
|