@turmind/halo 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -5
- package/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/298-4fac7d44a7949eec.js +1 -0
- package/admin-out/_next/static/chunks/{503-7c6d9935df6e0cea.js → 307-d7a99f9a4a436ec3.js} +1 -1
- package/admin-out/_next/static/chunks/app/page-b12a2e8e48fe4ea0.js +1 -0
- package/admin-out/_next/static/css/{8fce4e1a54dac12a.css → 372e3dbd4a0d1c25.css} +1 -1
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +4 -4
- package/bundled-docs/dev/acp-adapter.md +5 -4
- package/bundled-docs/dev/desktop-packaging.md +94 -8
- package/bundled-docs/guide/getting-started.md +3 -2
- package/bundled-docs/requirements/explorer.md +11 -0
- package/dist/index.js +13373 -13246
- package/package.json +1 -1
- package/templates/agents/executor/agent.yaml +2 -2
- package/templates/models/aws-bedrock-claude-invoke.yaml +26 -0
- package/templates/skills/acp/SKILL.md +8 -1
- package/templates/skills/acp/templates/SKILL.md.tmpl +2 -1
- package/templates/skills/acp/templates/ask.py +2 -1
- package/templates/skills/acp/templates/config.yaml.tmpl +5 -0
- package/admin-out/_next/static/chunks/298-d6b224b569da4de0.js +0 -1
- package/admin-out/_next/static/chunks/app/page-cc8043335282d745.js +0 -1
- package/bundled-docs/plans/cron-tasks.md +0 -362
- package/bundled-docs/plans/design-suggestions.md +0 -203
- package/bundled-docs/plans/issue-session-deep-nesting.md +0 -208
- package/bundled-docs/plans/launch-plan.md +0 -111
- package/bundled-docs/plans/md-and-agent-refactor.md +0 -197
- package/bundled-docs/plans/remote-mac-worker.md +0 -196
- package/bundled-docs/plans/roadmap.md +0 -40
- package/bundled-docs/plans/self-evolution.md +0 -1379
- package/bundled-docs/plans/todo.md +0 -32
- package/bundled-docs/plans/weixin-channel.md +0 -255
- package/bundled-docs/test/docs-seeding.md +0 -101
- package/bundled-docs/test/session.md +0 -374
- /package/admin-out/_next/static/{IBP_mwBDf-cPwZSZZGpi8 → ep7BWneNhTuIPwJfFFh72}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{IBP_mwBDf-cPwZSZZGpi8 → ep7BWneNhTuIPwJfFFh72}/_ssgManifest.js +0 -0
|
@@ -1,1379 +0,0 @@
|
|
|
1
|
-
# Self-Evolution
|
|
2
|
-
|
|
3
|
-
Frozen-design spec. Read the decision log at the bottom if a choice surprises you.
|
|
4
|
-
|
|
5
|
-
## Goal
|
|
6
|
-
|
|
7
|
-
Let an active workspace **learn from its own conversations** — when an agent
|
|
8
|
-
makes the same kind of mistake repeatedly, the user shouldn't have to remember
|
|
9
|
-
to fix the prompt. An evolution agent looks at the session, drafts a fix, runs
|
|
10
|
-
a sandbox dry-run to score it, and a separate apply agent merges approved
|
|
11
|
-
patches back. All under user review.
|
|
12
|
-
|
|
13
|
-
## Levels
|
|
14
|
-
|
|
15
|
-
Borrowing self-driving's L0-L5. Ship in order.
|
|
16
|
-
|
|
17
|
-
| Level | What it does | Status |
|
|
18
|
-
|---|---|---|
|
|
19
|
-
| **L0** | Fully manual. User edits prompt files themselves. | Already supported |
|
|
20
|
-
| **L1** | Human + LLM assist. `/note` and pre-compact trigger drafts; manual approve/reject; apply agent merges. | **MVP — this spec** |
|
|
21
|
-
| L2 | Partial auto-apply for high-score patches. | Future |
|
|
22
|
-
| L3 | Yaml/capability changes also auto-apply. | Future |
|
|
23
|
-
| L4 | Create/archive agents and skills. Curator pass. | Long-term |
|
|
24
|
-
|
|
25
|
-
L0 needs no code. L1 is what this spec covers. L2+ get a "Future levels" sketch.
|
|
26
|
-
|
|
27
|
-
---
|
|
28
|
-
|
|
29
|
-
# L1 MVP
|
|
30
|
-
|
|
31
|
-
## Three components
|
|
32
|
-
|
|
33
|
-
1. **Two hidden agents** in `~/.halo/global/agents/`:
|
|
34
|
-
- `__evo_agent__` — drafts patches and runs dry-run scoring
|
|
35
|
-
- `__apply_agent__` — merges approved patches into target files
|
|
36
|
-
Both are normal Halo agents with `internal: true` so they don't appear in
|
|
37
|
-
`list_agents`, the chat agent selector, `/new`'s default pick, and have no
|
|
38
|
-
Test button. They show up in admin's agent management (collapsed **Internal**
|
|
39
|
-
group) for editing.
|
|
40
|
-
|
|
41
|
-
2. **Two queues in global db** (`evolution_runs`, `evolution_applies`)
|
|
42
|
-
driven by a server-side ticker (every 30s). Ticker has zero in-memory state
|
|
43
|
-
— looks at db status fields and timestamps.
|
|
44
|
-
|
|
45
|
-
3. **Per-task wrapper processes** that the ticker spawns. Each wrapper:
|
|
46
|
-
- Runs as a Node child process (`packages/server/dist/evo-wrapper.js`)
|
|
47
|
-
- Updates db heartbeat every 60s
|
|
48
|
-
- Spawns the appropriate `halo cli -a __evo_agent__|__apply_agent__` to
|
|
49
|
-
do the LLM work
|
|
50
|
-
- Watches the cli's exit code and translates to db status
|
|
51
|
-
- Owns its own evo-db handle (separate sqlite file at
|
|
52
|
-
`~/.halo/global/evo.db`) — no need to call into the server process
|
|
53
|
-
because the entire coordination surface (heartbeat, status flips,
|
|
54
|
-
row claims) is just db writes
|
|
55
|
-
|
|
56
|
-
4. **Admin "Evolution" tab** for listing pendings, viewing patches, approving
|
|
57
|
-
with optional reviewer hint, rejecting, and deleting finished runs
|
|
58
|
-
(artifacts + DB row; blocked for in-flight states).
|
|
59
|
-
|
|
60
|
-
## File layout
|
|
61
|
-
|
|
62
|
-
```
|
|
63
|
-
<ws>/.halo/evo/
|
|
64
|
-
runs/<id>/ # one dir per evo run
|
|
65
|
-
meta.json # { id, triggerKind, sourceSession, userHint, ... }
|
|
66
|
-
source-snapshot.json # session messages frozen at trigger time
|
|
67
|
-
tool-flow.md # tool_result-clipped view of the snapshot
|
|
68
|
-
evo-context.json # prompt surface at trigger time (see below)
|
|
69
|
-
images/<msgIdx>-<blockIdx>.<ext> # decoded image content blocks
|
|
70
|
-
sandbox/ # cp of workspace .halo, mutated by evo
|
|
71
|
-
sub-cli.log # tee'd stdout+stderr of every `halo cli`
|
|
72
|
-
# the wrapper spawned (draft/fix/dry-run/score)
|
|
73
|
-
# — single file, phase-headered for easy reading
|
|
74
|
-
patch.md # evo agent writes
|
|
75
|
-
score.json # __score__ writes
|
|
76
|
-
.skip.md # evo writes if no patch is worth landing
|
|
77
|
-
applies/<id>/ # one dir per apply
|
|
78
|
-
meta.json # source_run_ids, reviewer_hint
|
|
79
|
-
sandbox/ # apply agent works here, runs verification
|
|
80
|
-
apply.log
|
|
81
|
-
regress/<runId>/ # per-source-run regression scoring outputs
|
|
82
|
-
history/apply-<id>/ # rollback snapshot before each apply
|
|
83
|
-
MANIFEST.json # which paths were overwritten/created
|
|
84
|
-
<files> # the previous content of those paths
|
|
85
|
-
archive/ # archive job zips terminal runs/applies
|
|
86
|
-
run-<id>.zip
|
|
87
|
-
apply-<id>.zip
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
`pending/` directory is gone. Pending list comes from `SELECT * FROM
|
|
91
|
-
evolution_runs WHERE status='awaiting_review' AND archived_at IS NULL`
|
|
92
|
-
in the global db. Patch content comes from `runs/<id>/patch.md`.
|
|
93
|
-
|
|
94
|
-
`evo-context.json` (written by `enqueueEvoRun()` at trigger time) is the
|
|
95
|
-
authoritative snapshot of the source agent's prompt surface — assembled
|
|
96
|
-
system prompt, the workspace + global prompt files that go into every
|
|
97
|
-
agent's system prompt (INSTRUCTIONS.md, USER.md, INDEX.md, the source
|
|
98
|
-
agent's AGENT.md + agent.yaml, prompts/{all,root}/*), plus agent and
|
|
99
|
-
skill id listings. Skill content (SKILL.md, sibling resources like
|
|
100
|
-
wechat.md / telegram.md) is **not** inlined — listings only. The wrapper
|
|
101
|
-
packs `evo-context.json` into briefs so evo doesn't need `file_read` to
|
|
102
|
-
inspect the system-prompt files; for skills, it `grep`s / `file_read`s
|
|
103
|
-
on demand using the new size-limited tools. Format:
|
|
104
|
-
|
|
105
|
-
```json
|
|
106
|
-
{
|
|
107
|
-
"agentId": "default",
|
|
108
|
-
"assembledSystemPrompt": "<full text>", // null if session was cold
|
|
109
|
-
"promptFiles": [
|
|
110
|
-
{ "scope": "workspace", "path": "INSTRUCTIONS.md", "content": "..." },
|
|
111
|
-
{ "scope": "global", "path": "agents/default/AGENT.md", "content": "..." },
|
|
112
|
-
{ "scope": "global", "path": "prompts/all/TOOL_GUIDELINES.md", "content": "..." },
|
|
113
|
-
...
|
|
114
|
-
],
|
|
115
|
-
"agents": [{ "id": "default", "scope": "global" }, ...],
|
|
116
|
-
"skills": [{ "id": "create-agent", "scope": "builtin" }, ...]
|
|
117
|
-
}
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
`tool-flow.md` is a clipped Markdown view of `source-snapshot.json` for
|
|
121
|
-
fast skimming. User prose, assistant prose, and `tool_use` calls (with
|
|
122
|
-
abbreviated `input`) are kept verbatim; each `tool_result` is replaced
|
|
123
|
-
with a ~200-char peek plus the `is_error` flag. Sized to fit easily in
|
|
124
|
-
context even for long sessions where grep/shell dumps would dominate.
|
|
125
|
-
Evo reads this first to understand the *flow* (which tools ran in what
|
|
126
|
-
order, what the user said between turns, whether each call succeeded);
|
|
127
|
-
it falls back to the full `source-snapshot.json` (or its inherited
|
|
128
|
-
message history) only when a specific tool result actually matters.
|
|
129
|
-
|
|
130
|
-
`images/` holds decoded base64 image content blocks from
|
|
131
|
-
`source-snapshot.json` (top-level or nested in tool_result.content). Same
|
|
132
|
-
images visible to evo as vision blocks in inherited messages; the file
|
|
133
|
-
paths exist so a patch can keep an image as a prompt resource by
|
|
134
|
-
referencing the file. Video / audio / binary documents are not
|
|
135
|
-
extracted (Halo doesn't currently produce non-image content blocks).
|
|
136
|
-
|
|
137
|
-
`history/apply-<id>/` is the rollback safety net for `applied` runs and
|
|
138
|
-
is **not** archived by the retention job — kept cheap and discoverable.
|
|
139
|
-
|
|
140
|
-
`archive/` is created on demand by the archive daemon (see schema
|
|
141
|
-
section above for retention policy). Per-row run/apply dirs are zipped
|
|
142
|
-
in, then the original dirs removed.
|
|
143
|
-
|
|
144
|
-
## Global db schema
|
|
145
|
-
|
|
146
|
-
```sql
|
|
147
|
-
CREATE TABLE evolution_runs (
|
|
148
|
-
id TEXT PRIMARY KEY,
|
|
149
|
-
workspace_path TEXT NOT NULL,
|
|
150
|
-
status TEXT NOT NULL, -- pending | running | awaiting_review |
|
|
151
|
-
-- approved | applied | rejected |
|
|
152
|
-
-- skipped | failed | timeout
|
|
153
|
-
trigger_kind TEXT NOT NULL, -- note | pre-compact
|
|
154
|
-
source_session TEXT NOT NULL,
|
|
155
|
-
user_hint TEXT, -- /note text + reviewer hint (replaced on retry)
|
|
156
|
-
created_at INTEGER NOT NULL,
|
|
157
|
-
started_at INTEGER,
|
|
158
|
-
heartbeat_at INTEGER,
|
|
159
|
-
completed_at INTEGER,
|
|
160
|
-
applied_at INTEGER,
|
|
161
|
-
failure_reason TEXT,
|
|
162
|
-
attempts INTEGER NOT NULL DEFAULT 0,
|
|
163
|
-
archived_at INTEGER -- 14d after terminal: zip + clear
|
|
164
|
-
);
|
|
165
|
-
|
|
166
|
-
CREATE TABLE evolution_applies (
|
|
167
|
-
id TEXT PRIMARY KEY,
|
|
168
|
-
workspace_path TEXT NOT NULL,
|
|
169
|
-
status TEXT NOT NULL, -- pending | running | syncing | applied | failed | timeout
|
|
170
|
-
source_run_ids TEXT NOT NULL, -- JSON array (one apply can fold N runs)
|
|
171
|
-
reviewer_hint TEXT, -- constraints from approver
|
|
172
|
-
created_at INTEGER NOT NULL,
|
|
173
|
-
started_at INTEGER,
|
|
174
|
-
heartbeat_at INTEGER,
|
|
175
|
-
completed_at INTEGER,
|
|
176
|
-
failure_reason TEXT,
|
|
177
|
-
attempts INTEGER NOT NULL DEFAULT 0,
|
|
178
|
-
archived_at INTEGER
|
|
179
|
-
);
|
|
180
|
-
|
|
181
|
-
CREATE INDEX idx_evo_runs_status ON evolution_runs(status, created_at);
|
|
182
|
-
CREATE INDEX idx_evo_runs_ws ON evolution_runs(workspace_path);
|
|
183
|
-
CREATE INDEX idx_evo_runs_archived ON evolution_runs(archived_at);
|
|
184
|
-
CREATE INDEX idx_evo_applies_status ON evolution_applies(status, created_at);
|
|
185
|
-
CREATE INDEX idx_evo_applies_ws ON evolution_applies(workspace_path);
|
|
186
|
-
CREATE INDEX idx_evo_applies_archived ON evolution_applies(archived_at);
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
### Archive lifecycle
|
|
190
|
-
|
|
191
|
-
`archived_at` is orthogonal to `status` — it tracks the disk-retention
|
|
192
|
-
state, not the run's outcome. Two stages:
|
|
193
|
-
|
|
194
|
-
1. **14 days after a row hits a terminal status** (`applied`, `rejected`,
|
|
195
|
-
`skipped`, `failed`, `timeout` for runs; `applied`, `failed`, `timeout`
|
|
196
|
-
for applies), the archive daemon zips the row's artifact dir into
|
|
197
|
-
`<workspace>/.halo/evo/archive/{run|apply}-<id>.zip`, deletes the
|
|
198
|
-
original dir, sets `archived_at = now()`. The DB row stays.
|
|
199
|
-
2. **30 days after `archived_at`**, the archive daemon deletes the zip
|
|
200
|
-
and the DB row outright.
|
|
201
|
-
|
|
202
|
-
The daemon runs once at server boot (1 minute delay) and every 24 hours
|
|
203
|
-
thereafter. Active rows (pending / running / awaiting_review / approved /
|
|
204
|
-
syncing) are never archived. The `history/apply-<id>/` rollback tree is
|
|
205
|
-
NOT archived — it stays cheap to discover for `applied` runs that may
|
|
206
|
-
need rollback.
|
|
207
|
-
|
|
208
|
-
**Manual delete** (`DELETE /evolution/runs/:id`) is a third, time-independent
|
|
209
|
-
exit: it removes a run's artifacts (live run dir + archive zip, whichever
|
|
210
|
-
exists) and its DB row immediately, skipping both time gates. Same active-row
|
|
211
|
-
guard as archiving — `pending` / `running` / `approved` are rejected (409) so
|
|
212
|
-
a live wrapper or queued apply isn't pulled out from under. Artifact removal
|
|
213
|
-
goes through `removeRunArtifacts()` in `archive.ts`, which shares the path
|
|
214
|
-
layout with the archive/purge helpers. Applies have no manual-delete path —
|
|
215
|
-
they're consolidated under their source runs in the UI.
|
|
216
|
-
|
|
217
|
-
The default API + admin views (`GET /evolution/runs`) exclude archived
|
|
218
|
-
rows. A dedicated `GET /evolution/runs?archived=1` endpoint and
|
|
219
|
-
admin "Archived" filter let the user inspect them.
|
|
220
|
-
|
|
221
|
-
## Realtime updates — WS broadcast (no client polling)
|
|
222
|
-
|
|
223
|
-
The admin Evolution tab subscribes to `evolution:run_changed` and
|
|
224
|
-
`evolution:apply_changed` events on the existing `/ws` channel. Server
|
|
225
|
-
emits them at every state-change point:
|
|
226
|
-
|
|
227
|
-
- REST mutations (approve/reject/retry) → broadcast directly with the
|
|
228
|
-
new status. Latency is "instant" from the user's keystroke. Delete
|
|
229
|
-
broadcasts `evolution:run_changed` with `kind:'deleted'` (no status) —
|
|
230
|
-
the sidebar drops the row, an open detail pane clears its selection.
|
|
231
|
-
- Wrapper child processes can't reach the parent's WSS handle, so the
|
|
232
|
-
ticker (every 30s) diff'ies the db against an in-memory snapshot of
|
|
233
|
-
last-seen statuses and broadcasts every change it finds. That covers
|
|
234
|
-
`pending → running → awaiting_review/skipped/failed/timeout`
|
|
235
|
-
transitions driven by the wrapper without any client poll.
|
|
236
|
-
|
|
237
|
-
Broadcast fan-out via `ws/broadcast.ts` to every connected admin socket.
|
|
238
|
-
Replaces an earlier `setInterval(refreshList, 5_000)` on every Evolution
|
|
239
|
-
tab — same UX, ~zero idle traffic.
|
|
240
|
-
|
|
241
|
-
## Triggers (L1)
|
|
242
|
-
|
|
243
|
-
### `/note [text]`
|
|
244
|
-
|
|
245
|
-
User in chat:
|
|
246
|
-
|
|
247
|
-
```
|
|
248
|
-
/note # evo runs on current root session
|
|
249
|
-
/note answer is too verbose # hint for what to look at
|
|
250
|
-
```
|
|
251
|
-
|
|
252
|
-
Server flow (synchronous, completes in ~10ms):
|
|
253
|
-
|
|
254
|
-
1. Refuse if `level !== 'L1'` or user is `accessLevel === 'readonly'`.
|
|
255
|
-
2. Resolve current root session id (must be `parentId === null`).
|
|
256
|
-
3. mkdir `<ws>/.halo/evo/runs/<id>/`
|
|
257
|
-
4. Write `meta.json` and `source-snapshot.json` (deep clone of current
|
|
258
|
-
`session.agent.messages`).
|
|
259
|
-
5. INSERT into `evolution_runs` with `status='pending'`, `user_hint=arg`.
|
|
260
|
-
6. Reply via `chat:system`: "📝 Queued for evaluation".
|
|
261
|
-
|
|
262
|
-
That's all that happens at trigger time. No LLM call yet. Ticker picks it up.
|
|
263
|
-
|
|
264
|
-
### Pre-compact
|
|
265
|
-
|
|
266
|
-
Inside `SessionManager.compactSession()`, **before the compression LLM call
|
|
267
|
-
mutates `session.agent.messages`**:
|
|
268
|
-
|
|
269
|
-
1. Same checks (level=L1, source session is root, etc.).
|
|
270
|
-
2. **Synchronously snapshot** the current messages — same as `/note` step 3-4:
|
|
271
|
-
mkdir runs/<id>/, write `meta.json` + `source-snapshot.json` (deep clone
|
|
272
|
-
of `session.agent.messages` taken right now, before compact starts).
|
|
273
|
-
3. INSERT `evolution_runs` row with `trigger_kind='pre-compact'`,
|
|
274
|
-
`user_hint=null`, `status='pending'`.
|
|
275
|
-
4. Continue with normal compact.
|
|
276
|
-
|
|
277
|
-
Compact then proceeds asynchronously rewriting `session.agent.messages`,
|
|
278
|
-
which is fine — evo will analyze the snapshot, not the live array. Order
|
|
279
|
-
matters: snapshot first, compact second. Otherwise evo sees the
|
|
280
|
-
already-compacted (= summarized + truncated) message log, which loses the
|
|
281
|
-
detail we wanted evo to learn from.
|
|
282
|
-
|
|
283
|
-
The "free LLM cache" angle that pre-compact originally had is gone with
|
|
284
|
-
async — that's fine.
|
|
285
|
-
|
|
286
|
-
### Why not idle
|
|
287
|
-
|
|
288
|
-
User-active feedback loops are short and useful; idle is "user has left,
|
|
289
|
-
runs in dark, finds out hours later". Rejected.
|
|
290
|
-
|
|
291
|
-
## Ticker (server-side, every 30s)
|
|
292
|
-
|
|
293
|
-
Stateless. Just queries db.
|
|
294
|
-
|
|
295
|
-
```
|
|
296
|
-
1. Mark timeouts:
|
|
297
|
-
UPDATE evolution_runs SET status='timeout', failure_reason='heartbeat lost'
|
|
298
|
-
WHERE status='running' AND heartbeat_at < now - run_timeout_minutes * 60_000
|
|
299
|
-
|
|
300
|
-
UPDATE evolution_applies SET status='timeout' (same condition)
|
|
301
|
-
|
|
302
|
-
2. Start runs:
|
|
303
|
-
running = SELECT count(*) FROM evolution_runs WHERE status='running'
|
|
304
|
-
slots = max_concurrent_run - running
|
|
305
|
-
for each pending run (oldest first, up to slots):
|
|
306
|
-
spawn('node', ['<server-dist>/evo-wrapper.js', '--mode=run', '--id=<X>'],
|
|
307
|
-
{ detached: true, stdio: 'ignore' })
|
|
308
|
-
|
|
309
|
-
3. Start applies (same pattern):
|
|
310
|
-
spawn('node', ['<server-dist>/evo-wrapper.js', '--mode=apply', '--id=<Y>'])
|
|
311
|
-
```
|
|
312
|
-
|
|
313
|
-
Server doesn't track child processes in memory. If server restarts, the
|
|
314
|
-
ticker re-evaluates db state on first tick:
|
|
315
|
-
|
|
316
|
-
- `status='running'` rows with stale heartbeat → marked `timeout`
|
|
317
|
-
- Pending rows continue to be picked up
|
|
318
|
-
|
|
319
|
-
Wrappers detach from server — `--detach` so they survive server restarts.
|
|
320
|
-
|
|
321
|
-
## Wrapper (Node child process)
|
|
322
|
-
|
|
323
|
-
`packages/server/src/evo-wrapper.ts` (compiled to `.js`).
|
|
324
|
-
|
|
325
|
-
```ts
|
|
326
|
-
const { mode, id } = parseArgs() // mode: 'run' | 'apply', id: <X>
|
|
327
|
-
const db = openGlobalDb()
|
|
328
|
-
|
|
329
|
-
await db.update(...).set({ status: 'running', started_at: now() }).where(eq(id, X))
|
|
330
|
-
|
|
331
|
-
// Heartbeat: every 60s
|
|
332
|
-
const hb = setInterval(async () => {
|
|
333
|
-
await db.update(...).set({ heartbeat_at: now() }).where(eq(id, X))
|
|
334
|
-
}, 60_000)
|
|
335
|
-
|
|
336
|
-
// Spawn the cli
|
|
337
|
-
const cli = spawn('halo', [
|
|
338
|
-
'cli',
|
|
339
|
-
'-a', mode === 'run' ? '__evo_agent__' : '__apply_agent__',
|
|
340
|
-
'-w', workspacePath,
|
|
341
|
-
buildBrief(mode, id, ...)
|
|
342
|
-
], { stdio: ['ignore', logFile, logFile] })
|
|
343
|
-
|
|
344
|
-
cli.on('exit', async (code) => {
|
|
345
|
-
clearInterval(hb)
|
|
346
|
-
if (code === 0 && allOutputFilesExist()) {
|
|
347
|
-
if (mode === 'run') {
|
|
348
|
-
await db.update(...).set({ status: 'awaiting_review', completed_at: now() })
|
|
349
|
-
} else {
|
|
350
|
-
// apply post-processing, see "Apply post-processing" below
|
|
351
|
-
await postApply(id)
|
|
352
|
-
}
|
|
353
|
-
} else {
|
|
354
|
-
await db.update(...).set({ status: 'failed', failure_reason: ..., completed_at: now() })
|
|
355
|
-
}
|
|
356
|
-
process.exit(0)
|
|
357
|
-
})
|
|
358
|
-
```
|
|
359
|
-
|
|
360
|
-
> **Resolving the `halo` binary (cross-platform).** `resolveHaloCli()`
|
|
361
|
-
> returns bare `halo` on macOS/Linux but **`halo.cmd` on Windows**: the
|
|
362
|
-
> desktop installer drops `halo.cmd` (cli launcher) and `Halo.exe` (the
|
|
363
|
-
> GUI) into the same dir on PATH, and PATHEXT ranks `.EXE` above `.CMD`, so a
|
|
364
|
-
> bare `halo cli …` would launch the GUI — which relaunches the app and
|
|
365
|
-
> grabs the global `server.lock` instead of running the cli. Spawning a
|
|
366
|
-
> `.cmd` directly is rejected by Node ≥21.7 (EINVAL, CVE-2024-27980), so on
|
|
367
|
-
> Windows the child is routed through `cmd.exe /c`. Override the whole thing
|
|
368
|
-
> with `$HALO_CLI` for dev. The real code also passes the brief on **stdin**
|
|
369
|
-
> (not as the trailing argv element shown above) to dodge the Windows
|
|
370
|
-
> command-line length limit.
|
|
371
|
-
>
|
|
372
|
-
> On **macOS**, bare `halo` resolves via PATH to the launcher the "Install
|
|
373
|
-
> 'halo' Command" menu drops in `/usr/local/bin`. But a desktop app launched
|
|
374
|
-
> from Finder/Dock has only launchd's minimal PATH (no `/usr/local/bin`), so
|
|
375
|
-
> the spawn would `ENOENT`. The Electron main process (`desktop/src/main.cjs`
|
|
376
|
-
> `startServer()`) compensates by prepending the standard CLI dirs to the
|
|
377
|
-
> server child's `env.PATH` — see `dev/desktop-packaging.md`.
|
|
378
|
-
|
|
379
|
-
### Wrapper logs (two streams, both surfaced in admin)
|
|
380
|
-
|
|
381
|
-
The wrapper writes two log streams that the admin Evolution tab reads
|
|
382
|
-
back lazily on detail fetch:
|
|
383
|
-
|
|
384
|
-
- **`~/.halo/global/logs/evo/run-<id>.log`** — wrapper's own
|
|
385
|
-
orchestration log. Phase headers, sub-cli command lines (last arg
|
|
386
|
-
omitted to avoid leaking the whole brief), exit codes, finalize
|
|
387
|
-
decisions. Cheap, always present once the wrapper starts.
|
|
388
|
-
|
|
389
|
-
- **`<runDir>/sub-cli.log`** — `stdout` + `stderr` of every `halo cli`
|
|
390
|
-
the wrapper spawned (draft, fix, dry-run, score), tee'd live to disk
|
|
391
|
-
in a single file with `=== <ts> Phase ... ===` markers between
|
|
392
|
-
sections. A single tail covers the whole run end-to-end. Captures the
|
|
393
|
-
inner cli's actual error messages (e.g. Anthropic API rejections) that
|
|
394
|
-
used to be buried in a 2 KB stderr-tail dump only emitted on non-zero
|
|
395
|
-
exit. The wrapper still keeps the in-memory stdout for `dry-run-output.txt`
|
|
396
|
-
on success — tee writes are additive, not redirective.
|
|
397
|
-
|
|
398
|
-
GET `/api/evolution/runs/:id` returns both as `wrapperLog` /
|
|
399
|
-
`subCliLog`. The admin detail panel renders them as collapsible
|
|
400
|
-
`<pre>` blocks (default open under 4 KB, collapsed otherwise) for both
|
|
401
|
-
successful and failed runs — successful runs benefit from seeing how
|
|
402
|
-
long each phase took / whether the fix budget was used; failed runs
|
|
403
|
-
need them to diagnose why.
|
|
404
|
-
|
|
405
|
-
### Apply post-processing
|
|
406
|
-
|
|
407
|
-
After the apply cli exits 0:
|
|
408
|
-
|
|
409
|
-
1. Walk `applies/<id>/sandbox/.halo/` and find files that differ from main
|
|
410
|
-
`<ws>/.halo/`. Those are what the apply agent decided to write.
|
|
411
|
-
2. Snapshot the changed paths from main `<ws>/.halo/` → `history/<ts>--apply-<X>/`.
|
|
412
|
-
(Snapshot now, after we know what changed — saves space vs blanket cp at
|
|
413
|
-
wrapper start.)
|
|
414
|
-
3. Copy each changed file from sandbox to main. Use Node `fs/promises`,
|
|
415
|
-
not `rsync` or `cp` (cross-platform).
|
|
416
|
-
4. UPDATE `evolution_applies status='applied'`.
|
|
417
|
-
5. UPDATE all source_run_ids' `evolution_runs` rows to `status='applied'`.
|
|
418
|
-
|
|
419
|
-
That's it. **No "release in-memory sessions" step** — Halo's session
|
|
420
|
-
lifecycle makes it unnecessary. See [Session lifecycle: cold-start per
|
|
421
|
-
turn](#session-lifecycle-cold-start-per-turn) for the full picture, but
|
|
422
|
-
the short version: every session is evicted from the SessionManager
|
|
423
|
-
cache as soon as its current message turn finishes. The next turn runs
|
|
424
|
-
`buildAgentInstance` from scratch, which re-reads `agent.yaml` +
|
|
425
|
-
`INSTRUCTIONS.md` + `USER.md` from disk. So the just-applied changes
|
|
426
|
-
take effect on the next turn for free, and currently-running turns
|
|
427
|
-
finish on the old prompts (deliberate — aborting a user mid-conversation
|
|
428
|
-
would be a worse experience than a slight prompt-change delay).
|
|
429
|
-
|
|
430
|
-
If sandbox doesn't exist or has no changes (apply agent decided nothing was
|
|
431
|
-
worth writing): mark `complete` anyway with a note in `failure_reason`. The
|
|
432
|
-
runs are still marked `applied` since the user's intent was satisfied (no
|
|
433
|
-
change needed).
|
|
434
|
-
|
|
435
|
-
If cli exits non-zero or in-sandbox tests failed: main workspace is
|
|
436
|
-
untouched (apply agent wrote only to sandbox). Just mark `failed`.
|
|
437
|
-
|
|
438
|
-
## Platform override matrix (reference for evo / score)
|
|
439
|
-
|
|
440
|
-
Both `__evo_agent__` and `__score__` need to reason about how prompt files
|
|
441
|
-
load at runtime — what evo writes ends up in a workspace, and the scorer
|
|
442
|
-
needs to know whether that workspace file actually overrides the global
|
|
443
|
-
one. This is the canonical reference; AGENT.md files for both agents
|
|
444
|
-
include the same table.
|
|
445
|
-
|
|
446
|
-
| File / dir | Override rule |
|
|
447
|
-
|---|---|
|
|
448
|
-
| `INSTRUCTIONS.md` | Workspace `<ws>/.halo/INSTRUCTIONS.md` **fully suppresses** the global one. Subdirectory `INSTRUCTIONS.md` files (under `<ws>/<sub>/.halo/`) layer additively on top of the workspace root. |
|
|
449
|
-
| `agents/<id>/` | **Whole-folder override.** Workspace `<ws>/.halo/agents/<id>/` exists → serves the agent entirely (both `AGENT.md` and `agent.yaml`), global folder ignored, no per-file fallback. |
|
|
450
|
-
| `skills/<id>/` | **Whole-folder override.** Workspace `skills/<id>/` replaces the global skill wholesale — `SKILL.md` + every sibling resource. |
|
|
451
|
-
| `prompts/all/`, `prompts/root/`, `prompts/bootstrap/` | **Whole-folder override.** If the workspace scope directory exists at all, the entire global directory for that scope is ignored. |
|
|
452
|
-
| `USER.md` | Workspace replaces global. |
|
|
453
|
-
|
|
454
|
-
**The whole-folder trap** (applies to `agents/`, `skills/`, `prompts/`
|
|
455
|
-
alike): if a patch creates a workspace folder containing only the one file
|
|
456
|
-
it edited, every *other* file the global folder had becomes invisible at
|
|
457
|
-
runtime. To override one file inside such a folder, the patch must `cp` the
|
|
458
|
-
entire global folder first. The sharpest case is an `agents/<id>/` left
|
|
459
|
-
with only `AGENT.md` — `agent.yaml` is gone, so the agent has no model
|
|
460
|
-
config. Mention this explicitly in any patch that touches a whole-folder
|
|
461
|
-
target.
|
|
462
|
-
|
|
463
|
-
Implementation: agent/skill folder resolution in
|
|
464
|
-
`packages/server/src/agents/agent-loader.ts` (`agentSourceDir`,
|
|
465
|
-
`skillSourceDir`), AGENT.md in `prompts/md-loader.ts` (`resolveMdPaths`),
|
|
466
|
-
prompt scopes in `prompts/system-prompts.ts` (`resolvePromptsDir`).
|
|
467
|
-
Behavior is whole-folder "first match wins" not per-file merge.
|
|
468
|
-
|
|
469
|
-
## What the agents see
|
|
470
|
-
|
|
471
|
-
### Run mode is split across THREE wrapper-orchestrated phases
|
|
472
|
-
|
|
473
|
-
The wrapper, not the agent, drives the whole `mode=run` lifecycle. Three
|
|
474
|
-
phases each spawn a fresh `halo cli` for one job; the wrapper owns all
|
|
475
|
-
the sub-cli invocations against the sandbox so individual agents can't
|
|
476
|
-
"go off-script" by retrying with edited prompts.
|
|
477
|
-
|
|
478
|
-
```
|
|
479
|
-
Phase A (draft): halo cli -a __evo_agent__ -n -w <runDir>/sandbox "<draft brief>"
|
|
480
|
-
Phase B (dry-run, retry-on-error):
|
|
481
|
-
halo cli -a <triggeringId> -n --access workspace -w <runDir>/sandbox "<testMessage>"
|
|
482
|
-
on failure:
|
|
483
|
-
halo cli -a __evo_agent__ -n -w <runDir>/sandbox "<fix brief + error log>"
|
|
484
|
-
Phase C (score): halo cli -a __score__ -n -w <runDir>/sandbox "<score brief>"
|
|
485
|
-
```
|
|
486
|
-
|
|
487
|
-
All phases run inside `<runDir>/sandbox/` (whitelist-cp of the user
|
|
488
|
-
workspace's prompt surface), so no agent_sessions rows or evo state
|
|
489
|
-
ever touch the user's real workspace db.
|
|
490
|
-
|
|
491
|
-
Three structural points apply across the phases:
|
|
492
|
-
|
|
493
|
-
1. **Snapshot reading via on-disk files (no session inheritance).**
|
|
494
|
-
Each phase spawn is a fresh `-n` session. The drafter / fix / scorer
|
|
495
|
-
read context from disk via `file_read`:
|
|
496
|
-
- `<runDir>/source-snapshot.json` — full rawMessages (the source
|
|
497
|
-
conversation)
|
|
498
|
-
- `<runDir>/tool-flow.md` — clipped view, ~200-char peeks per
|
|
499
|
-
tool_result with the `is_error` flag (read this first to skim)
|
|
500
|
-
- `<runDir>/evo-context.json` — the prompt surface at trigger time,
|
|
501
|
-
packed into the brief verbatim so the drafter doesn't have to
|
|
502
|
-
re-read prompt files
|
|
503
|
-
|
|
504
|
-
No internal session is staged, no `agent_sessions` row is written
|
|
505
|
-
anywhere. The drafter and scorer are stateless across runs, and
|
|
506
|
-
their stdout / stderr land in `<runDir>/sub-cli.log` for review.
|
|
507
|
-
|
|
508
|
-
Earlier iterations of this design pre-staged the snapshot's
|
|
509
|
-
rawMessages into a global internal-sessions JSON file and resumed
|
|
510
|
-
the agent via `cli -s evo_<runId>` so the LLM "inherited" the full
|
|
511
|
-
conversation. That worked for Anthropic-to-Anthropic flows but
|
|
512
|
-
needed a sanitizer pipeline (thinking-block stripping, dangling
|
|
513
|
-
tool_use repair, role-alternation merge, etc.) to make
|
|
514
|
-
cross-provider rawMessages legal Anthropic input. The whole edifice
|
|
515
|
-
was deleted in favor of `-n` + on-disk reads — same information
|
|
516
|
-
reaches the agent, no replay-shape constraints to satisfy, no
|
|
517
|
-
staged session to garbage-collect.
|
|
518
|
-
|
|
519
|
-
And because all phase-A/A-fix/C invocations also `-w <runDir>/sandbox`
|
|
520
|
-
instead of the user workspace, any incidental writes (e.g. a future
|
|
521
|
-
internal agent that creates a sub-session) land in the sandbox's
|
|
522
|
-
brand-new db, which gets discarded with the runDir.
|
|
523
|
-
|
|
524
|
-
2. **Prompt surface packed into the brief, plus on-demand reading.** The
|
|
525
|
-
wrapper bakes the **system-prompt files** into the brief at trigger
|
|
526
|
-
time (captured by `enqueueEvoRun()` into `<runDir>/evo-context.json`)
|
|
527
|
-
so the agents never have to fetch them: the assembled system prompt
|
|
528
|
-
the source agent was running under, every workspace + global
|
|
529
|
-
INSTRUCTIONS.md / USER.md / INDEX.md, the source agent's AGENT.md /
|
|
530
|
-
agent.yaml, prompts/all/*, prompts/root/*, plus id-only listings of
|
|
531
|
-
every agent and skill in the workspace. Skill content (SKILL.md and
|
|
532
|
-
sibling resource files) is **not** inlined — at scale that would blow
|
|
533
|
-
up the brief regardless of which skill the patch actually touches.
|
|
534
|
-
|
|
535
|
-
For everything not in the brief, both agents have a read-only toolkit
|
|
536
|
-
they can use on demand: `file_read` (with offset/limit + 2 MB cap),
|
|
537
|
-
`grep`, `glob`, `file_list`. Typical pattern: `glob` or `grep` to
|
|
538
|
-
locate a specific SKILL.md → `file_read` it → reason → write the
|
|
539
|
-
patch. The size guards keep this safe — pre-guard, a single
|
|
540
|
-
`file_read` of an oversized file could consume tens of thousands of
|
|
541
|
-
tokens.
|
|
542
|
-
|
|
543
|
-
Writing toolkit:
|
|
544
|
-
- **evo**: `file_write`, `file_edit` (write `patch.md` + sandbox
|
|
545
|
-
target file; iterate on patch.md inline if needed). No
|
|
546
|
-
`shell_exec` — evo doesn't execute anything; the dry-run is run by
|
|
547
|
-
the wrapper, not by evo.
|
|
548
|
-
- **scorer**: `file_write` only (writes `score.json`). No
|
|
549
|
-
`file_edit` / `shell_exec` — the scorer is read-only on the
|
|
550
|
-
workspace, write-only on `score.json`.
|
|
551
|
-
|
|
552
|
-
3. **Image dump.** Before phase A spawns, the wrapper walks the snapshot
|
|
553
|
-
`rawMessages`, decodes every `type:'image'` content block (top-level
|
|
554
|
-
or nested in tool_result.content) to
|
|
555
|
-
`<runDir>/images/<msgIdx>-<blockIdx>.<ext>`. Brief includes a manifest
|
|
556
|
-
listing each path with role / msgIdx / media_type / size / adjacent
|
|
557
|
-
text. The same images are visible to the agent as vision blocks in
|
|
558
|
-
message history; the file paths exist for the rare case where a patch
|
|
559
|
-
needs to keep an image as a prompt resource.
|
|
560
|
-
|
|
561
|
-
Video / audio / binary documents are NOT extracted — Halo currently
|
|
562
|
-
has no `view_video` / `view_audio` tool, and snapshots therefore
|
|
563
|
-
don't carry such blocks. Brief carries a generic "Non-text media note"
|
|
564
|
-
instructing the agent to skip the run if the patch logically depends
|
|
565
|
-
on understanding media internals it can't see.
|
|
566
|
-
|
|
567
|
-
#### Phase A — `__evo_agent__` drafts
|
|
568
|
-
|
|
569
|
-
The brief packs everything; AGENT.md describes the procedure.
|
|
570
|
-
|
|
571
|
-
```
|
|
572
|
-
You are running as the Evolution drafter (mode: DRAFT).
|
|
573
|
-
|
|
574
|
-
Run id: <X>
|
|
575
|
-
Workspace: <ws>
|
|
576
|
-
Working dir: <runDir>
|
|
577
|
-
Trigger: note | pre-compact
|
|
578
|
-
Reviewer hint: <if set>
|
|
579
|
-
|
|
580
|
-
Your conversation history is the triggering agent's full message log.
|
|
581
|
-
Scroll back to inspect what the user wanted, what the agent did, and
|
|
582
|
-
where it diverged. The current prompt surface follows.
|
|
583
|
-
|
|
584
|
-
[evo-context.json rendered:
|
|
585
|
-
- assembled system prompt
|
|
586
|
-
- prompt file dump (workspace + global)
|
|
587
|
-
- agent + skill listing]
|
|
588
|
-
|
|
589
|
-
[image manifest, if any images extracted]
|
|
590
|
-
|
|
591
|
-
[non-text media note]
|
|
592
|
-
|
|
593
|
-
=== Task ===
|
|
594
|
-
Decide what change in the prompt surface above would help next time.
|
|
595
|
-
Three shapes, in order of preference: rewrite an existing rule;
|
|
596
|
-
tighten / reorganize what's there; or, only when neither applies, add
|
|
597
|
-
a new rule.
|
|
598
|
-
|
|
599
|
-
Sandbox path: <runDir>/sandbox
|
|
600
|
-
|
|
601
|
-
When you have a change worth making, write `patch.md` (frontmatter + body)
|
|
602
|
-
and one new file at `<runDir>/sandbox/.halo/<target>` with the full new
|
|
603
|
-
contents.
|
|
604
|
-
|
|
605
|
-
When the conversation has no signal, or your patch logically requires
|
|
606
|
-
understanding media you can't see, write `<runDir>/.skip.md` with a one
|
|
607
|
-
or two sentence reason. .skip.md is the LAST file you write — wrapper
|
|
608
|
-
checks it after patch.md, takes it as your final word.
|
|
609
|
-
|
|
610
|
-
[language clause]
|
|
611
|
-
```
|
|
612
|
-
|
|
613
|
-
The agent has only `file_list` and `file_write`. It can't `file_read` the
|
|
614
|
-
sandbox, but the brief already has every prompt file content; the only
|
|
615
|
-
reason `file_list` exists is to verify a sandbox subdirectory before
|
|
616
|
-
writing into it.
|
|
617
|
-
|
|
618
|
-
#### Phase B — wrapper runs the dry-run, calls evo back to fix on error
|
|
619
|
-
|
|
620
|
-
```
|
|
621
|
-
timeout 180 halo cli -a <patch.testScenario.agentId>
|
|
622
|
-
-n --access workspace
|
|
623
|
-
-w <runDir>/sandbox
|
|
624
|
-
"<patch.testScenario.testMessage>"
|
|
625
|
-
```
|
|
626
|
-
|
|
627
|
-
`--access workspace` is critical: bwrap masks `~/.aws`, `~/.ssh`,
|
|
628
|
-
`~/.kube`, `~/.docker`, `~/.gnupg`, `~/.halo/secrets` so a probe
|
|
629
|
-
mentioning AWS / cloud / system credentials can't actually fire side
|
|
630
|
-
effects. File and shell ops inside the sandbox itself are unaffected.
|
|
631
|
-
Same `--access workspace` is used in apply mode's regression dry-runs.
|
|
632
|
-
|
|
633
|
-
Outcomes:
|
|
634
|
-
- **Exit 0 + non-empty stdout:** save to `<runDir>/dry-run-output.txt`,
|
|
635
|
-
proceed to phase C.
|
|
636
|
-
- **Anything else** (non-zero, empty, timeout): save the failure detail to
|
|
637
|
-
`<runDir>/dry-run-fail-<n>.log`, then re-spawn `__evo_agent__` resuming
|
|
638
|
-
the SAME `evo_<runId>` session with a **fix brief**. The fix brief
|
|
639
|
-
inlines the failure log content (no `file_read` needed). The agent
|
|
640
|
-
remembers its own draft from the resumed messages and decides what to
|
|
641
|
-
change. After it exits, the wrapper retries the dry-run.
|
|
642
|
-
|
|
643
|
-
`FIX_BUDGET = 1` — exactly one corrective pass. If the second attempt
|
|
644
|
-
still fails, the run is marked `failed` with
|
|
645
|
-
`failure_reason='dry-run never succeeded'`. We deliberately don't loop:
|
|
646
|
-
- Two-pass shape (original + one corrective edit) covers the common
|
|
647
|
-
failure modes (bad yaml, malformed frontmatter, scope-too-aggressive
|
|
648
|
-
test scenario) without spiralling.
|
|
649
|
-
- More than one fix tends to look like the old "edit prompt and retry"
|
|
650
|
-
anti-pattern — if a patch can't be made dry-run-clean in one
|
|
651
|
-
corrective edit, it's signal that the patch should be rejected, not
|
|
652
|
-
iterated on.
|
|
653
|
-
|
|
654
|
-
#### Phase C — `__score__` scores
|
|
655
|
-
|
|
656
|
-
The brief packs everything; the scorer has only `file_write`.
|
|
657
|
-
|
|
658
|
-
```
|
|
659
|
-
You are running as the Evolution scorer.
|
|
660
|
-
|
|
661
|
-
Run id: <X>
|
|
662
|
-
Workspace: <ws>
|
|
663
|
-
Working dir: <runDir>
|
|
664
|
-
|
|
665
|
-
Your conversation history is the original conversation the patch was
|
|
666
|
-
drafted from. The current prompt surface and patch context follow.
|
|
667
|
-
|
|
668
|
-
[evo-context.json rendered]
|
|
669
|
-
|
|
670
|
-
=== patch.md ===
|
|
671
|
-
[full content]
|
|
672
|
-
|
|
673
|
-
=== dry-run-output.txt ===
|
|
674
|
-
(stdout from the wrapper running the patched sandbox against
|
|
675
|
-
testScenario.testMessage. Two execution constraints to keep in mind:
|
|
676
|
-
1. Sandbox runs under --access workspace, so AWS / system-credential
|
|
677
|
-
operations are expected to fail with "Unable to locate credentials".
|
|
678
|
-
Score on what KIND of response the agent gave, not on whether
|
|
679
|
-
external side effects fired.
|
|
680
|
-
2. Inline images are visible vision blocks; video / audio / binary
|
|
681
|
-
documents are not. Patches whose value depends on media internals
|
|
682
|
-
naturally show low-signal dry-runs and that's correctly reflected
|
|
683
|
-
in `confidence: low` rather than punished via behavior score.)
|
|
684
|
-
[full content]
|
|
685
|
-
|
|
686
|
-
=== meta.json ===
|
|
687
|
-
[full content]
|
|
688
|
-
|
|
689
|
-
=== Task ===
|
|
690
|
-
From patch.md frontmatter, take testScenario.originalMessage and find the
|
|
691
|
-
assistant turn that immediately followed it in your message history —
|
|
692
|
-
that's the baseline. Compare baseline to dry-run-output.txt, rate lint /
|
|
693
|
-
behavior / scope, write `<runDir>/score.json`.
|
|
694
|
-
|
|
695
|
-
[language clause for the "notes" field]
|
|
696
|
-
```
|
|
697
|
-
|
|
698
|
-
Splitting drafter vs scorer means the scorer doesn't have ego attached to
|
|
699
|
-
the patch. The same scorer is reused at apply time (phase 11) to validate
|
|
700
|
-
the merged sandbox before main is touched.
|
|
701
|
-
|
|
702
|
-
#### Why `halo cli` and not `start_session`?
|
|
703
|
-
|
|
704
|
-
`start_session` reuses the parent session's SessionManager state — the
|
|
705
|
-
sub-agent reads prompts from the real workspace, not the sandbox, so the
|
|
706
|
-
patch never actually loads. `halo cli -w <sandbox>` runs in its own
|
|
707
|
-
process and reads `<sandbox>/.halo/` as the workspace, so the patch is
|
|
708
|
-
genuinely live in the test.
|
|
709
|
-
|
|
710
|
-
#### Brief design — consequence framing, not negation
|
|
711
|
-
|
|
712
|
-
The briefs and the AGENT.md files for `__evo_agent__` / `__score__` /
|
|
713
|
-
`__apply_agent__` use **consequence/affordance language** rather than
|
|
714
|
-
imperative or negation phrasing:
|
|
715
|
-
|
|
716
|
-
- "Reading the README first anchors your questions" not "Don't open with
|
|
717
|
-
questions"
|
|
718
|
-
- "Padding dilutes directness" not "Don't pad"
|
|
719
|
-
- "Edits outside `<applyDir>/` would corrupt main workspace" not "Never
|
|
720
|
-
edit outside `<applyDir>/`"
|
|
721
|
-
|
|
722
|
-
The reasoning: imperative ("do X") and negation ("don't X") are weaker
|
|
723
|
-
LLM signals than statements of consequence; the model treats negations
|
|
724
|
-
especially poorly because the X token still elevates that action's
|
|
725
|
-
probability. **Hard constraints belong in the tool layer**, not the
|
|
726
|
-
prompt — the evo agent literally cannot run `halo cli` because
|
|
727
|
-
`shell_exec` isn't in its tool set, so the prompt doesn't need to say
|
|
728
|
-
"do NOT spawn halo cli". Same for "don't write score.json" (no
|
|
729
|
-
`file_write` outside the sandbox is enforced by sandbox path validation,
|
|
730
|
-
not prompt).
|
|
731
|
-
|
|
732
|
-
What stays as prompt-level guidance: style preferences, work shape,
|
|
733
|
-
optional / discretionary boundaries that benefit from the agent reasoning
|
|
734
|
-
about edge cases.
|
|
735
|
-
|
|
736
|
-
Mode-specific bits:
|
|
737
|
-
- **Draft brief** carries `Trigger`, optional `Reviewer hint` (replaces
|
|
738
|
-
`User hint` on retry — see retry policy below).
|
|
739
|
-
- **Fix brief** carries `Fix attempt: 1/1` and the failure log content
|
|
740
|
-
inline (no separate `file_read`).
|
|
741
|
-
- **Score brief** is the largest because it inlines patch.md +
|
|
742
|
-
dry-run-output.txt + meta.json + the prompt surface.
|
|
743
|
-
|
|
744
|
-
### Apply mode is wrapper-orchestrated, mirrors run mode
|
|
745
|
-
|
|
746
|
-
Same shape as `mode=run`: wrapper drives, agents do focused work,
|
|
747
|
-
`__score__` is reused as a regression gate. Key difference is the
|
|
748
|
-
wrapper, not the apply agent, builds the sandbox **and** runs the
|
|
749
|
-
per-source-run validation loop (the apply agent only merges).
|
|
750
|
-
|
|
751
|
-
```
|
|
752
|
-
Phase A' (merge): halo cli -a __apply_agent__ -n -w <ws> "<merge brief>"
|
|
753
|
-
Phase B' (regress): for each source_run_id Xi:
|
|
754
|
-
halo cli -a <patch.testScenario.agentId> -n -w <applyDir>/sandbox \
|
|
755
|
-
"<patch.testScenario.testMessage>"
|
|
756
|
-
halo cli -a __score__ -n -w <ws> "<regress brief for Xi>"
|
|
757
|
-
all scores OK → proceed to sync (phase 12)
|
|
758
|
-
any score regresses → mark apply failed, main untouched
|
|
759
|
-
```
|
|
760
|
-
|
|
761
|
-
#### Apply working dir
|
|
762
|
-
|
|
763
|
-
```
|
|
764
|
-
<ws>/.halo/evo/applies/<applyId>/
|
|
765
|
-
meta.json # { source_run_ids: [...], reviewer_hint }
|
|
766
|
-
sandbox/.halo/ # built by wrapper from main <ws>/.halo/, edited by apply agent
|
|
767
|
-
regress/<runId>/ # one dir per source run, written by phase B'
|
|
768
|
-
dry-run-output.txt
|
|
769
|
-
score.json
|
|
770
|
-
apply.log
|
|
771
|
-
```
|
|
772
|
-
|
|
773
|
-
The apply sandbox is **separate** from any evo run sandbox:
|
|
774
|
-
|
|
775
|
-
- A reviewer can approve N runs into a single apply, so we'd have to
|
|
776
|
-
combine N evo sandboxes anyway. Cleaner to start fresh.
|
|
777
|
-
- `patch.md` may have been edited by the reviewer between approve time
|
|
778
|
-
and apply time — apply must read the *latest* patch.md.
|
|
779
|
-
- The main workspace may have changed during review (other apples
|
|
780
|
-
applied, user edits). Apply should be based on **current** workspace
|
|
781
|
-
state, not the evo run's frozen sandbox.
|
|
782
|
-
|
|
783
|
-
#### Phase A' — `__apply_agent__` merges into a wrapper-built sandbox
|
|
784
|
-
|
|
785
|
-
The **wrapper**, before spawning the apply agent, does the same
|
|
786
|
-
whitelist cp evo's phase A does — but from the **current main
|
|
787
|
-
workspace** into `<applyDir>/sandbox/.halo/`. Apply agent walks into
|
|
788
|
-
that pre-built sandbox.
|
|
789
|
-
|
|
790
|
-
```
|
|
791
|
-
Your job: merge N approved evolution patches into the apply sandbox.
|
|
792
|
-
You do NOT run dry-runs and you do NOT score the merged result —
|
|
793
|
-
the wrapper handles both, separately, using __score__.
|
|
794
|
-
|
|
795
|
-
Apply id: <Y>
|
|
796
|
-
Working dir: <ws>/.halo/evo/applies/<Y>/
|
|
797
|
-
source_run_ids: [X1, X2, ...]
|
|
798
|
-
Reviewer hint (may be empty): <text>
|
|
799
|
-
|
|
800
|
-
Steps:
|
|
801
|
-
1. Read meta.json (source_run_ids + reviewer_hint).
|
|
802
|
-
2. For each source_run_id Xi, file_read <ws>/.halo/evo/runs/Xi/patch.md.
|
|
803
|
-
Use the **latest** version — the reviewer may have edited it after
|
|
804
|
-
approving.
|
|
805
|
-
3. Look at the wrapper-built sandbox at <applyDir>/sandbox/.halo/.
|
|
806
|
-
Note which target files are present and their current content
|
|
807
|
-
(which is the latest main-workspace content, not evo's frozen
|
|
808
|
-
snapshot).
|
|
809
|
-
4. Apply each patch's changes to sandbox/.halo/<target>:
|
|
810
|
-
- file_edit / file_write within the sandbox only.
|
|
811
|
-
- Respect the platform override matrix (see above) — if a patch
|
|
812
|
-
wants to change a file that currently lives only in
|
|
813
|
-
~/.halo/global/, you MUST materialize the workspace override
|
|
814
|
-
first. For whole-folder targets (agents/<id>/, skills/<id>/,
|
|
815
|
-
prompts/<scope>/), copy the *entire global folder* before editing
|
|
816
|
-
one file — otherwise the workspace folder's mere existence
|
|
817
|
-
suppresses every other file the global folder had (an agents/<id>/
|
|
818
|
-
with only AGENT.md loses agent.yaml → no model config). For the
|
|
819
|
-
single-file overrides (INSTRUCTIONS.md, USER.md), copy that file in
|
|
820
|
-
first, then edit.
|
|
821
|
-
- When two patches touch the same target, merge by intent: read both
|
|
822
|
-
bodies, decide what the combined edit should look like. Don't try
|
|
823
|
-
a textual diff merge.
|
|
824
|
-
- reviewer_hint is a hard constraint that overrides individual
|
|
825
|
-
patches: if it says "only apply X, skip Y", you skip Y entirely.
|
|
826
|
-
- If patches are genuinely contradictory and reviewer_hint doesn't
|
|
827
|
-
resolve it, write the conflict to apply.log and exit non-zero —
|
|
828
|
-
the reviewer needs to split the apply.
|
|
829
|
-
5. Exit 0 once the sandbox reflects all approved + reconciled
|
|
830
|
-
changes. The wrapper takes over from here.
|
|
831
|
-
```
|
|
832
|
-
|
|
833
|
-
#### Phase B' — wrapper runs per-source regression check via `__score__`
|
|
834
|
-
|
|
835
|
-
For each source run id `Xi`, the wrapper:
|
|
836
|
-
|
|
837
|
-
1. Reads `<ws>/.halo/evo/runs/Xi/patch.md` to get
|
|
838
|
-
`testScenario.{agentId, testMessage, originalMessage}`.
|
|
839
|
-
2. Spawns `timeout 180 halo cli -a <agentId> -n -w
|
|
840
|
-
<applyDir>/sandbox "<testMessage>"`.
|
|
841
|
-
3. Saves stdout to `regress/Xi/dry-run-output.txt`.
|
|
842
|
-
4. Spawns `__score__` with a regress-mode brief pointing at
|
|
843
|
-
`regress/Xi/`. Scorer reads patch.md, the new dry-run output, and
|
|
844
|
-
source-snapshot.json — same procedure as phase 9C but in apply
|
|
845
|
-
context. Writes `regress/Xi/score.json`.
|
|
846
|
-
|
|
847
|
-
A regression is **any score.json** with `behavior < 50` (clearly worse
|
|
848
|
-
than baseline) or `lint < 50` (patched config doesn't load cleanly).
|
|
849
|
-
Thresholds live in `general.evolution.apply_regression_*` settings
|
|
850
|
-
(future); start with hardcoded 50/50.
|
|
851
|
-
|
|
852
|
-
If all source runs pass the regression check → wrapper proceeds to
|
|
853
|
-
phase 12 (final sync to main). If any regresses → wrapper marks
|
|
854
|
-
`evolution_applies status='failed'`, main workspace untouched, source
|
|
855
|
-
runs stay `approved` so the user can split or edit and try again.
|
|
856
|
-
|
|
857
|
-
## Patch file (`runs/<id>/patch.md`)
|
|
858
|
-
|
|
859
|
-
Written by `__evo_agent__` in phase A. Natural-language description, NOT
|
|
860
|
-
a diff. The frontmatter is structured (the wrapper parses it for the
|
|
861
|
-
dry-run; the scorer parses it for baseline lookup); the body is for
|
|
862
|
-
human review.
|
|
863
|
-
|
|
864
|
-
```markdown
|
|
865
|
-
---
|
|
866
|
-
target: .halo/INSTRUCTIONS.md
|
|
867
|
-
testScenario:
|
|
868
|
-
agentId: default
|
|
869
|
-
originalMessage: "做成csv文件"
|
|
870
|
-
testMessage: "做一个 CSV 存到 /tmp/x.csv,含中文表头,给 Mac Excel 用"
|
|
871
|
-
---
|
|
872
|
-
|
|
873
|
-
## What to change
|
|
874
|
-
…
|
|
875
|
-
## Why
|
|
876
|
-
…
|
|
877
|
-
```
|
|
878
|
-
|
|
879
|
-
Frontmatter keys stay English (they're contract — wrapper / scorer parse
|
|
880
|
-
them). Body is in the user's language (`general.language`).
|
|
881
|
-
|
|
882
|
-
### `testScenario` — two messages, two purposes
|
|
883
|
-
|
|
884
|
-
| Field | Used by | Constraint |
|
|
885
|
-
|---|---|---|
|
|
886
|
-
| `agentId` | wrapper (dry-run) + scorer (frontmatter parse) | id of the **triggering agent** from `source-snapshot.json` |
|
|
887
|
-
| `originalMessage` | scorer | verbatim user message from `rawMessages` — the assistant turn that follows it is the "before" baseline |
|
|
888
|
-
| `testMessage` | wrapper | clean self-contained probe **drafted by evo** to surgically exercise the patched rule. Should not depend on prior conversation context. |
|
|
889
|
-
|
|
890
|
-
Why split them:
|
|
891
|
-
- The wrapper's dry-run sub-cli is a fresh session with no prior turns —
|
|
892
|
-
so the probe must be self-contained ("做一个含中文的 csv" works; "做成
|
|
893
|
-
csv文件" needs a table from earlier in the chat that the sub-agent
|
|
894
|
-
doesn't have).
|
|
895
|
-
- The scorer needs to compare *the patched agent's reply on the probe*
|
|
896
|
-
against *the original agent's reply on the original message* — which
|
|
897
|
-
means it needs the original text, verbatim, to find the right baseline
|
|
898
|
-
turn in the snapshot.
|
|
899
|
-
- If `testMessage` and `originalMessage` happen to be identical clean
|
|
900
|
-
probes, the drafter may write the same string twice — that's allowed.
|
|
901
|
-
Most snapshot turns aren't clean probes, so they differ in practice.
|
|
902
|
-
|
|
903
|
-
Back-compat: older patches (before the split) wrote a single `message`
|
|
904
|
-
field. The wrapper falls back to using it for both meanings. New runs
|
|
905
|
-
should always use the two-field shape.
|
|
906
|
-
|
|
907
|
-
`score` is **not** in the frontmatter — it lives only in `score.json`,
|
|
908
|
-
written by the scorer in phase C. Older runs may have a stale `score:`
|
|
909
|
-
block from before this split; the wrapper / UI ignore it.
|
|
910
|
-
|
|
911
|
-
The apply agent reads this whole markdown and decides how to actually merge
|
|
912
|
-
it. There's no `operation: append/replace` field — the apply agent makes that
|
|
913
|
-
call.
|
|
914
|
-
|
|
915
|
-
## Score (`runs/<id>/score.json`)
|
|
916
|
-
|
|
917
|
-
Written by `__score__` in phase C — never by the drafter. Splitting
|
|
918
|
-
drafter and scorer is the whole reason patches don't get inflated by the
|
|
919
|
-
agent that proposed them.
|
|
920
|
-
|
|
921
|
-
```json
|
|
922
|
-
{
|
|
923
|
-
"lint": 90,
|
|
924
|
-
"behavior": 75,
|
|
925
|
-
"scope": 80,
|
|
926
|
-
"confidence": "high",
|
|
927
|
-
"avg": 82,
|
|
928
|
-
"notes": "Sandbox sub-agent gave a more specific answer with citations. Minor risk of being too prescriptive on edge cases."
|
|
929
|
-
}
|
|
930
|
-
```
|
|
931
|
-
|
|
932
|
-
Each dim is 0-100. Anchor each at 50 = "neutral / no signal" so the
|
|
933
|
-
scorer doesn't default to all-50 when unsure.
|
|
934
|
-
|
|
935
|
-
- **lint** (0-100): did the patched prompt files load cleanly when the
|
|
936
|
-
wrapper ran the dry-run? yaml valid, cross-refs resolve, no contradictions
|
|
937
|
-
with sibling files. 100 = clean, 50 = unclear, 0 = patched config didn't
|
|
938
|
-
load at all (dry-run failed even after fix attempts).
|
|
939
|
-
- **behavior** (0-100): is the dry-run output (in `dry-run-output.txt`)
|
|
940
|
-
better than the original assistant reply that followed
|
|
941
|
-
`testScenario.message` in the snapshot? 100 = clearly better, 50 =
|
|
942
|
-
indistinguishable / mixed, 0 = clearly worse.
|
|
943
|
-
- **scope** (0-100): surgical (100) vs sweeping (0). Touching one workspace
|
|
944
|
-
file with 3 lines = high; rewriting an agent's whole AGENT.md = low.
|
|
945
|
-
- **confidence** (low/medium/high): scorer's own confidence in the call.
|
|
946
|
-
Independent of the numeric scores — confidence "high" with all 50s is
|
|
947
|
-
legal ("I'm sure this is a wash").
|
|
948
|
-
|
|
949
|
-
`avg = round((lint + behavior + scope) / 3)` — single 0-100 number for
|
|
950
|
-
sorting in the admin UI. Future L2 thresholds use this + confidence.
|
|
951
|
-
|
|
952
|
-
The same scorer is reused at apply time (phase 11): after the apply agent
|
|
953
|
-
merges N approved runs into a sandbox, the wrapper invokes the scorer
|
|
954
|
-
again on each source run's `testScenario` to confirm the merged sandbox
|
|
955
|
-
still produces the improvement (regression gate before main is touched).
|
|
956
|
-
|
|
957
|
-
## Admin Evolution tab
|
|
958
|
-
|
|
959
|
-
A top-level tab. Shows:
|
|
960
|
-
|
|
961
|
-
- **List**: rows from `evolution_runs` joined with `evolution_applies`
|
|
962
|
-
(latest per source_run). Sortable by created_at, status, score.
|
|
963
|
-
Filterable by status; default views exclude archived rows.
|
|
964
|
-
- **Status filters** (sidebar buttons): `all`, `awaiting_review`,
|
|
965
|
-
`pending`, `running`, `approved`, `applied`, `skipped`, `rejected`,
|
|
966
|
-
`failed`, `timeout`, `archived`. The `archived` filter is a special
|
|
967
|
-
pseudo-status: it triggers `GET /evolution/runs?archived=1` (different
|
|
968
|
-
endpoint param, returns the archived list); all other filters operate
|
|
969
|
-
on the active list.
|
|
970
|
-
- **View**: opens patch.md, score.json, test scenario, the assembled
|
|
971
|
-
brief context (so the reviewer can see what the agents saw), diff
|
|
972
|
-
against current target.
|
|
973
|
-
- **Approve** (visible when status=`awaiting_review`): opens dialog with
|
|
974
|
-
optional reviewer_hint textarea. On submit:
|
|
975
|
-
- UPDATE evolution_runs status='approved'
|
|
976
|
-
- INSERT evolution_applies (source_run_ids=[X], reviewer_hint, status='pending')
|
|
977
|
-
- **Reject** (awaiting_review): UPDATE evolution_runs status='rejected'.
|
|
978
|
-
- **Add hint** (awaiting_review): UPDATE evolution_runs.user_hint += text.
|
|
979
|
-
Status unchanged. Memo for the apply agent; doesn't trigger anything.
|
|
980
|
-
- **Retry** (awaiting_review + every terminal status except `applied`
|
|
981
|
-
and `archived`): opens dialog with required hint textarea. On submit:
|
|
982
|
-
- UPDATE evolution_runs SET status='pending', attempts=0,
|
|
983
|
-
started_at=NULL, heartbeat_at=NULL, completed_at=NULL,
|
|
984
|
-
failure_reason=NULL, user_hint=<new hint>
|
|
985
|
-
- Ticker picks it up on the next pass and re-spawns the wrapper.
|
|
986
|
-
Hint is required — same input + no new direction would just reproduce
|
|
987
|
-
the same output. Replaces user_hint (not append) because the new run
|
|
988
|
-
is a fresh attempt.
|
|
989
|
-
|
|
990
|
-
Endpoint: `POST /api/evolution/runs/:id/retry { hint }`. Refuses
|
|
991
|
-
retry on `running` (would race the live wrapper) or `pending` (already
|
|
992
|
-
queued).
|
|
993
|
-
|
|
994
|
-
The user can also edit `runs/<id>/patch.md` directly (file_edit) before
|
|
995
|
-
approving — apply agent re-reads at apply time.
|
|
996
|
-
|
|
997
|
-
Multiple approvals on the same target: each Approve creates an
|
|
998
|
-
independent `evolution_applies` row, runs serially. Future "batch
|
|
999
|
-
apply" UI can group N approved runs into one apply row.
|
|
1000
|
-
|
|
1001
|
-
## Settings
|
|
1002
|
-
|
|
1003
|
-
```yaml
|
|
1004
|
-
general:
|
|
1005
|
-
language: en-US # en-US | zh-CN (BCP-47); globalOnly
|
|
1006
|
-
evolution:
|
|
1007
|
-
level: L0 # L0 | L1
|
|
1008
|
-
max_concurrent_run: 1
|
|
1009
|
-
max_concurrent_apply: 1
|
|
1010
|
-
run_timeout_minutes: 5
|
|
1011
|
-
apply_timeout_minutes: 5
|
|
1012
|
-
max_attempts: 3 # how many times the ticker may try to spawn a wrapper
|
|
1013
|
-
triggers:
|
|
1014
|
-
pre_compact: true
|
|
1015
|
-
```
|
|
1016
|
-
|
|
1017
|
-
L0 = disabled. L1 = enabled. Defaults are conservative; adjust if your
|
|
1018
|
-
machine handles more.
|
|
1019
|
-
|
|
1020
|
-
`/note` is always available when level=L1. Not in settings as a toggle.
|
|
1021
|
-
|
|
1022
|
-
### `general.language` (system-wide)
|
|
1023
|
-
|
|
1024
|
-
Stored as a BCP-47 region tag. Single source of truth — the toolbar
|
|
1025
|
-
language switcher in the admin UI reads and writes the same value.
|
|
1026
|
-
Internal agents (`__evo_agent__`, `__score__`, future `__apply_agent__`)
|
|
1027
|
-
skip platform prompts (see [Internal-agent isolation](#internal-agent-isolation))
|
|
1028
|
-
so they wouldn't otherwise know what language the user prefers; the
|
|
1029
|
-
wrapper resolves this setting and threads it into every brief it builds.
|
|
1030
|
-
|
|
1031
|
-
`config.language` collapses BCP-47 to the simpler `'en' | 'zh'` shape
|
|
1032
|
-
that the rest of the codebase already speaks (see `i18n.ts`'s `Lang`
|
|
1033
|
-
type). New region tags would need a small map update there if we ever
|
|
1034
|
-
add `ja-JP` etc.
|
|
1035
|
-
|
|
1036
|
-
## Retry
|
|
1037
|
-
|
|
1038
|
-
Two flavors of retry — one automatic (heartbeat-driven), one reviewer-driven.
|
|
1039
|
-
|
|
1040
|
-
### Automatic (heartbeat timeout)
|
|
1041
|
-
|
|
1042
|
-
Both `evolution_runs` and `evolution_applies` carry an `attempts` column.
|
|
1043
|
-
Each `pending → running` claim atomically bumps `attempts += 1`.
|
|
1044
|
-
|
|
1045
|
-
When heartbeat times out:
|
|
1046
|
-
- `attempts < max_attempts` → flip back to `pending` (clear started_at /
|
|
1047
|
-
heartbeat_at) so the next tick re-claims it (with attempts += 1 again).
|
|
1048
|
-
- `attempts >= max_attempts` → terminal `timeout` with
|
|
1049
|
-
`failure_reason: 'gave up after N attempts'`.
|
|
1050
|
-
|
|
1051
|
-
A permanently broken task burns at most `max_attempts` wrapper attempts
|
|
1052
|
-
before stopping, instead of looping forever.
|
|
1053
|
-
|
|
1054
|
-
### Reviewer-driven (admin Retry button)
|
|
1055
|
-
|
|
1056
|
-
`POST /api/evolution/runs/:id/retry { hint }` resets the row:
|
|
1057
|
-
|
|
1058
|
-
```sql
|
|
1059
|
-
UPDATE evolution_runs
|
|
1060
|
-
SET status='pending', attempts=0,
|
|
1061
|
-
started_at=NULL, heartbeat_at=NULL, completed_at=NULL,
|
|
1062
|
-
failure_reason=NULL, user_hint=<new hint>
|
|
1063
|
-
WHERE id=:id
|
|
1064
|
-
```
|
|
1065
|
-
|
|
1066
|
-
Allowed from any terminal status (`failed`, `timeout`, `skipped`,
|
|
1067
|
-
`rejected`, `awaiting_review`); refused on `running` (race with live
|
|
1068
|
-
wrapper) or `pending` (already queued).
|
|
1069
|
-
|
|
1070
|
-
The hint is required — empty hint means same input + same context, which
|
|
1071
|
-
just reproduces the previous output. Hint replaces `user_hint` (not
|
|
1072
|
-
append) because the new run is a fresh attempt against new direction;
|
|
1073
|
-
the appended-memo path is `/hint` (separate endpoint, status unchanged).
|
|
1074
|
-
|
|
1075
|
-
Retry on `applied` is intentionally not allowed — the patch already
|
|
1076
|
-
landed in main; producing another patch over the same source needs a
|
|
1077
|
-
new `/note` to anchor against the post-apply baseline.
|
|
1078
|
-
|
|
1079
|
-
Retry doesn't bump a "retry counter" anywhere — the user can retry as
|
|
1080
|
-
many times as they want. The reasoning: rate-limiting reviewer-driven
|
|
1081
|
-
retries would push a frustrated user to delete-and-recreate the row,
|
|
1082
|
-
which is more work and less observable.
|
|
1083
|
-
|
|
1084
|
-
## Internal-agent isolation
|
|
1085
|
-
|
|
1086
|
-
Agents flagged `internal: true` in their `agent.yaml` (`__evo_agent__`,
|
|
1087
|
-
`__score__`, future `__apply_agent__`) are **platform tooling, not
|
|
1088
|
-
workspace assistants**. They should not inherit any workspace context:
|
|
1089
|
-
no `INSTRUCTIONS.md`, no `USER.md`, no `INDEX.md`, no
|
|
1090
|
-
`prompts/all|root|bootstrap`. Only their own `AGENT.md` plus the brief
|
|
1091
|
-
the wrapper hands them.
|
|
1092
|
-
|
|
1093
|
-
Why:
|
|
1094
|
-
- **Token budget**: snapshots can run 100KB+; loading another 5–20KB of
|
|
1095
|
-
workspace platform prompts on top is dead weight.
|
|
1096
|
-
- **Behavior bleed**: workspace rules like "Be concise" or persona-shaping
|
|
1097
|
-
prompts make the drafter / scorer second-guess procedure that's
|
|
1098
|
-
supposed to be deterministic.
|
|
1099
|
-
- **Cross-workspace consistency**: an evo run launched from workspace A
|
|
1100
|
-
vs. workspace B should behave the same way; loading workspace prompts
|
|
1101
|
-
would tilt evo toward whatever rules the *current* workspace happens
|
|
1102
|
-
to have.
|
|
1103
|
-
|
|
1104
|
-
Implementation: `SessionManager.startSession` checks `yamlConfig.internal
|
|
1105
|
-
=== true` and zeroes out `mdContents.{userMd, globalInstructions,
|
|
1106
|
-
workspaceInstructionsChain, projectIndex, needsBootstrap}` plus
|
|
1107
|
-
`systemPrompts.{bootstrap, all, root}` before composing the system
|
|
1108
|
-
prompt. Internal agents still get tools, model config, prompt caching
|
|
1109
|
-
etc. — only the platform-md surface is suppressed.
|
|
1110
|
-
|
|
1111
|
-
## Session lifecycle: cold-start per turn
|
|
1112
|
-
|
|
1113
|
-
This is a Halo-platform fact that the entire phase 12 design depends
|
|
1114
|
-
on. Worth pinning here because misunderstanding it leads to
|
|
1115
|
-
over-engineering (e.g. building a "release all sessions on apply"
|
|
1116
|
-
hook that turned out to be unnecessary).
|
|
1117
|
-
|
|
1118
|
-
### How `SessionManager` actually caches sessions
|
|
1119
|
-
|
|
1120
|
-
`SessionManager.sessions` is a `Map<sessionId, AgentSession>` of
|
|
1121
|
-
**currently-mid-message** sessions only. The cache lifecycle is:
|
|
1122
|
-
|
|
1123
|
-
1. User message arrives → `ensureSession(id)`:
|
|
1124
|
-
- if id is in the Map → return that AgentSession
|
|
1125
|
-
- else → load metadata from sqlite + load saved messages from
|
|
1126
|
-
`<ws>/.halo/sessions/<agentId>/<id>.json` + run
|
|
1127
|
-
`buildAgentInstance` (which reads `agent.yaml`, `INSTRUCTIONS.md`,
|
|
1128
|
-
`USER.md`, all `prompts/` from disk) → put result in the Map
|
|
1129
|
-
2. Run the LLM turn (model call + tool calls + assistant reply)
|
|
1130
|
-
3. `runSession`'s finally block calls `releaseSession(id)`:
|
|
1131
|
-
- persist message log to disk (`saveAgentState`)
|
|
1132
|
-
- **delete from the in-memory Map**
|
|
1133
|
-
4. Session is now back to "only on disk". Next message → step 1 again.
|
|
1134
|
-
|
|
1135
|
-
So **between user messages, no session is in memory**. Each turn is a
|
|
1136
|
-
cold start: re-read all prompt files, build a fresh `ModelRuntime`
|
|
1137
|
-
instance, run, save, evict.
|
|
1138
|
-
|
|
1139
|
-
### What this means for evo / apply
|
|
1140
|
-
|
|
1141
|
-
The natural consequence: **any change to `agent.yaml`, `INSTRUCTIONS.md`,
|
|
1142
|
-
`USER.md`, or `prompts/**` takes effect on the very next user message**,
|
|
1143
|
-
without any cache-invalidation work from the rest of the system.
|
|
1144
|
-
Phase 12 of evo apply just needs to copy the sandbox files into main —
|
|
1145
|
-
the prompts kick in for free on the next turn, in every channel
|
|
1146
|
-
(wechat, telegram, web, TUI, cli, sub-agent `start_session`).
|
|
1147
|
-
|
|
1148
|
-
In-flight sessions (LLM call running right now when phase 12 cp lands)
|
|
1149
|
-
finish on the *old* prompts — also correct: aborting a user
|
|
1150
|
-
mid-conversation to make a prompt change visible 200ms earlier would be
|
|
1151
|
-
a worse trade.
|
|
1152
|
-
|
|
1153
|
-
### Implications
|
|
1154
|
-
|
|
1155
|
-
- No "release sessions on apply" hook needed in phase 12.
|
|
1156
|
-
- No cross-process coordination between wrapper and server for prompt
|
|
1157
|
-
invalidation — the in-memory cache simply doesn't live long enough
|
|
1158
|
-
for staleness to matter.
|
|
1159
|
-
- A wrapper can be a fully detached process (its own evo-db handle, no
|
|
1160
|
-
shared state with server) without any caching consequences.
|
|
1161
|
-
- Server restart is a no-op for users — every "lost" session was about
|
|
1162
|
-
to be released anyway, and gets restored from disk on the next turn.
|
|
1163
|
-
|
|
1164
|
-
### Inverse: what does NOT take effect immediately
|
|
1165
|
-
|
|
1166
|
-
A handful of things ARE cached for the lifetime of the server process,
|
|
1167
|
-
not per-turn:
|
|
1168
|
-
|
|
1169
|
-
- The `models/<provider-id>.yaml` registry (loaded once at server boot;
|
|
1170
|
-
see `loadModelsRegistry` in `config.ts`). Editing one of these
|
|
1171
|
-
requires a server restart.
|
|
1172
|
-
- The `general` settings schema (cached at boot). Same — restart for
|
|
1173
|
-
schema additions, though *values* in `settings.yaml` are mtime-watched
|
|
1174
|
-
and pick up live.
|
|
1175
|
-
- Compiled tool registries inside individual `SessionManager`s
|
|
1176
|
-
(workspace tools list). Today these are static, not user-tunable
|
|
1177
|
-
beyond the `tools:` array in `agent.yaml`, which IS picked up
|
|
1178
|
-
per-turn.
|
|
1179
|
-
|
|
1180
|
-
If a future feature adds long-lived in-memory state that needs to
|
|
1181
|
-
respond to evo apply, *that's* when an explicit invalidation hook
|
|
1182
|
-
would be worth designing — not for prompt files, which already work.
|
|
1183
|
-
|
|
1184
|
-
## Constraints / safety
|
|
1185
|
-
|
|
1186
|
-
- Evo only observes **root sessions** (`parentId === null`). Sub-agent sessions
|
|
1187
|
-
don't trigger evo on their own.
|
|
1188
|
-
- Evo is **invisible to the conversation**: no `user`/`assistant` messages
|
|
1189
|
-
appended to source session log. One short `chat:system` line per `/note`.
|
|
1190
|
-
- Wrappers detach from server — server restart doesn't kill in-flight
|
|
1191
|
-
evaluations. Heartbeat-based timeout cleans up if a wrapper crashes.
|
|
1192
|
-
- `/note` is rejected for `accessLevel === 'readonly'` users.
|
|
1193
|
-
- Wrappers never modify `~/.halo/global/` directly — only the workspace's
|
|
1194
|
-
`.halo/` (via copy-on-write inside the apply agent).
|
|
1195
|
-
- The `__evo_agent__` and `__apply_agent__` are normal agents — they can be
|
|
1196
|
-
modified by users (or by themselves via evo, like any other agent). User
|
|
1197
|
-
approves their own self-modifications.
|
|
1198
|
-
|
|
1199
|
-
## Implementation order
|
|
1200
|
-
|
|
1201
|
-
Status legend: ✅ done · 🔄 in progress · ❌ not started.
|
|
1202
|
-
|
|
1203
|
-
1. ✅ Settings schema (`level`, max_concurrent, timeouts, triggers)
|
|
1204
|
-
2. ✅ Global db: `evolution_runs`, `evolution_applies` tables (`~/.halo/global/evo.db`)
|
|
1205
|
-
3. ✅ Hidden agent infrastructure: `internal: true` field; filter in
|
|
1206
|
-
`list_agents` / `query_agent` / `start_session`; admin API surfaces it;
|
|
1207
|
-
admin sidebar shows them in a separate "Internal" section
|
|
1208
|
-
4. ✅ Seed `__evo_agent__` and `__apply_agent__` agent.yaml + AGENT.md
|
|
1209
|
-
placeholders (full procedure in `AGENT.md` is still TODO — see step 9)
|
|
1210
|
-
5. ✅ `/note` slash command — writes `runs/<id>/{meta,source-snapshot}.json` and INSERTs a `pending` row in `evolution_runs`. Hidden from `/help` when level≠L1 or user is readonly.
|
|
1211
|
-
6. ✅ Pre-compact hook in `SessionManager.selfCompactSession()` — snapshots
|
|
1212
|
-
the session log + INSERTs `pending` row before the compaction LLM rewrites
|
|
1213
|
-
messages. Gated on `evolution.level=L1` + `triggers.pre_compact=true` +
|
|
1214
|
-
root sessions only. Helper extracted to `evolution/enqueue.ts` and reused
|
|
1215
|
-
by `/note`.
|
|
1216
|
-
7. ✅ Server-side ticker (every 30s) — heartbeat timeout, atomic claim,
|
|
1217
|
-
`attempts` retry budget, placeholder spawner
|
|
1218
|
-
8. ✅ Evo wrapper (Node) — mode=run. Spawns `halo cli -a __evo_agent__ -n
|
|
1219
|
-
-w <ws> "<brief>"`, heartbeats every 60s, translates exit code +
|
|
1220
|
-
patch.md/score.json existence into terminal db status
|
|
1221
|
-
9. ✅ Wrapper-orchestrated 3-phase run mode (replaces the original "agent
|
|
1222
|
-
does everything" approach because Opus 4.7 wouldn't honour soft rules
|
|
1223
|
-
like "exactly one sub-cli call"):
|
|
1224
|
-
- **Phase A** — `__evo_agent__` drafts only: reads snapshot + current
|
|
1225
|
-
prompts, builds sandbox (whitelist), applies draft, writes patch.md
|
|
1226
|
-
with `testScenario` frontmatter (double field —
|
|
1227
|
-
`originalMessage` for scorer baseline lookup, `testMessage` for
|
|
1228
|
-
wrapper dry-run), exits. Does NOT run dry-runs or score itself.
|
|
1229
|
-
- **Phase B** — wrapper runs the dry-run: `timeout 180 halo cli -a
|
|
1230
|
-
<agentId> -n -w <sandbox> "<testScenario.testMessage>"`. On failure
|
|
1231
|
-
calls `__evo_agent__` back in fix mode with the error log + a "fix
|
|
1232
|
-
only, do NOT run" brief; one corrective pass (`FIX_BUDGET=1`).
|
|
1233
|
-
Successful dry-run stdout lands in `<runDir>/dry-run-output.txt`.
|
|
1234
|
-
- **Phase C** — `__score__` (separate agent, no shell_exec) reads
|
|
1235
|
-
patch.md + dry-run output + source-snapshot.json, writes
|
|
1236
|
-
score.json. Locates "before" baseline in snapshot via
|
|
1237
|
-
`testScenario.originalMessage`. No file edits beyond score.json.
|
|
1238
|
-
Reused at apply time as the regression gate.
|
|
1239
|
-
- **Internal-agent isolation**: agents flagged `internal: true`
|
|
1240
|
-
(evo, score, future apply) skip USER.md / INSTRUCTIONS.md /
|
|
1241
|
-
INDEX.md / prompts/all|root|bootstrap. Only their own AGENT.md +
|
|
1242
|
-
wrapper brief reaches the LLM. Reduces token cost and removes
|
|
1243
|
-
workspace-rule bleed-through.
|
|
1244
|
-
- **Language wiring**: wrapper resolves `general.language`
|
|
1245
|
-
(BCP-47) → `langHint` (`English` / `简体中文`), threads it into all
|
|
1246
|
-
three briefs as an *imperative* `Output language: write …` clause
|
|
1247
|
-
(passive `User language: …` headers were ignored by the LLM).
|
|
1248
|
-
Frontmatter keys / yaml schema stay English regardless.
|
|
1249
|
-
- **Whitelist sandbox cp**: only `INSTRUCTIONS.md / agents / prompts
|
|
1250
|
-
/ skills` are copied into `<runDir>/sandbox/.halo/`. Other
|
|
1251
|
-
directories (sessions, db, logs, channels) are workspace state that
|
|
1252
|
-
the dry-run sub-cli rebuilds from scratch.
|
|
1253
|
-
- Backend route `readScoreAvg` reads `score.json` directly. Stale
|
|
1254
|
-
`score:` blocks in older patch.md frontmatter are ignored.
|
|
1255
|
-
10. ✅ Admin Evolution tab UI (list / view / approve / reject). New top-level
|
|
1256
|
-
tab `Evolution` (Sparkles icon) renders a two-pane list+detail. Backend
|
|
1257
|
-
routes at `/api/evolution/runs` (list/detail) + `/approve` / `/reject` /
|
|
1258
|
-
`/hint`. Approve queues a pending `evolution_applies` row; nothing
|
|
1259
|
-
actually merges yet — that's phase 11/12.
|
|
1260
|
-
11. ✅ Apply wrapper (Node) + `__apply_agent__/AGENT.md`, two sub-phases
|
|
1261
|
-
that mirror evo's run mode:
|
|
1262
|
-
- **Phase A' — merge**: wrapper builds a fresh sandbox by whitelist-cp
|
|
1263
|
-
from the **current main workspace**, then spawns
|
|
1264
|
-
`__apply_agent__` to merge all approved patches into it. Apply
|
|
1265
|
-
agent reasons over the platform override matrix (same knowledge
|
|
1266
|
-
as evo) so workspace-replaces-global cases (INSTRUCTIONS.md /
|
|
1267
|
-
AGENT.md / agent.yaml) get the whole-global-file copied first,
|
|
1268
|
-
and `prompts/<scope>/` adds copy the entire global directory
|
|
1269
|
-
first. reviewer_hint is a hard cap on what the agent will apply.
|
|
1270
|
-
Apply agent writes `apply.log` (audit trail) and edits
|
|
1271
|
-
`<applyDir>/sandbox/.halo/...` only.
|
|
1272
|
-
- **Phase B' — regress**: wrapper, NOT the agent, loops over
|
|
1273
|
-
`source_run_ids`. Per run: parses `testScenario` from the run's
|
|
1274
|
-
patch.md, runs `timeout 180 halo cli -a <agentId> -n -w
|
|
1275
|
-
<applyDir>/sandbox "<testMessage>"`, saves stdout to
|
|
1276
|
-
`regress/<runId>/dry-run-output.txt`, then spawns `__score__` to
|
|
1277
|
-
write `regress/<runId>/score.json`. `lint < 50` or `behavior <
|
|
1278
|
-
50` in any score → mark apply `failed`. All scores OK → continue
|
|
1279
|
-
to phase 12.
|
|
1280
|
-
- **Main workspace stays untouched throughout phase 11** — only
|
|
1281
|
-
`<applyDir>/sandbox/` holds the merged state.
|
|
1282
|
-
12. ✅ Final sync. After phase B' all scores pass, the wrapper splits
|
|
1283
|
-
phase 12 into three checkpointed steps:
|
|
1284
|
-
- **Preflight**: walk sandbox, diff against main, snapshot pre-apply
|
|
1285
|
-
state of changed paths to `history/apply-<id>/` (with
|
|
1286
|
-
`MANIFEST.json`). Idempotent — on a resume, MANIFEST + history
|
|
1287
|
-
backup are skipped (the originals are already correct).
|
|
1288
|
-
- **Checkpoint**: write `evolution_applies.status='syncing'` BEFORE
|
|
1289
|
-
the dangerous step. From here on, a wrapper crash leaves the row
|
|
1290
|
-
in 'syncing' state; the ticker picks it up and respawns a wrapper
|
|
1291
|
-
that takes a resume branch (skipping all LLM-heavy phases A'/B').
|
|
1292
|
-
- **Publish**: cp each `changed` file from sandbox to main. Crash
|
|
1293
|
-
mid-cp is the only "real" failure mode — main may be half-applied,
|
|
1294
|
-
but `history/apply-<id>/` has the rollback material and the resume
|
|
1295
|
-
path will retry the cp (idempotent).
|
|
1296
|
-
|
|
1297
|
-
On success: mark each `source_run_id` in `evolution_runs` as
|
|
1298
|
-
`applied` (with `applied_at` timestamp), then mark the apply row
|
|
1299
|
-
`applied`. **No "release in-memory sessions" step** — see
|
|
1300
|
-
[Session lifecycle](#session-lifecycle-cold-start-per-turn).
|
|
1301
|
-
|
|
1302
|
-
All 12 phases shipped. The L1 MVP loop now closes end-to-end:
|
|
1303
|
-
`/note` or pre-compact → ticker spawns evo wrapper → patch.md +
|
|
1304
|
-
score.json → reviewer approves in admin → apply wrapper merges +
|
|
1305
|
-
regress-tests via `__score__` → phase 12 cps to main with history
|
|
1306
|
-
snapshot → next user turn picks up the new prompts.
|
|
1307
|
-
|
|
1308
|
-
---
|
|
1309
|
-
|
|
1310
|
-
# Future levels
|
|
1311
|
-
|
|
1312
|
-
Sketches.
|
|
1313
|
-
|
|
1314
|
-
## L2
|
|
1315
|
-
|
|
1316
|
-
- Auto-apply patches with `score.avg >= 80 && confidence == 'high'` AND
|
|
1317
|
-
target ∈ {INSTRUCTIONS.md, custom prompts}. Same wrapper, just bypasses
|
|
1318
|
-
manual approve step (creates `evolution_applies` directly).
|
|
1319
|
-
- Settings: `auto_apply_threshold: 80`, per-target opt-in flags.
|
|
1320
|
-
|
|
1321
|
-
## L3
|
|
1322
|
-
|
|
1323
|
-
- New patch kinds for yaml mutations (`tool-rewire`, `skill-rewire`).
|
|
1324
|
-
- Idle trigger added.
|
|
1325
|
-
|
|
1326
|
-
## L4
|
|
1327
|
-
|
|
1328
|
-
- `agent-create`, `skill-create`, `archive` kinds.
|
|
1329
|
-
- Curator periodic invocation that consolidates pending pile + active
|
|
1330
|
-
prompts.
|
|
1331
|
-
|
|
1332
|
-
---
|
|
1333
|
-
|
|
1334
|
-
# Decision log
|
|
1335
|
-
|
|
1336
|
-
- **Why L0/L1 ladder?** Each level has consistent risk profile; user mental
|
|
1337
|
-
model is simple ("I'm at L1").
|
|
1338
|
-
- **Why ship L1 first?** Need real accept/reject data before automating
|
|
1339
|
-
anything.
|
|
1340
|
-
- **Why a ticker, not server-internal scheduler with in-memory state?**
|
|
1341
|
-
Restart-safe; failure modes are simple (everything reads db).
|
|
1342
|
-
- **Why heartbeat in wrapper, not in cli session?** Cli session focused on
|
|
1343
|
-
the LLM work — adding "must update db every minute" is a distraction.
|
|
1344
|
-
Wrapper is the right place for liveness.
|
|
1345
|
-
- **Why two hidden agents instead of one?** They have different skills and
|
|
1346
|
-
different prompts. evo is "analyst + critic"; apply is "merger + validator".
|
|
1347
|
-
Splitting keeps each focused.
|
|
1348
|
-
- **Why patch.md is natural-language, not a diff?** Multiple approvals merge
|
|
1349
|
-
better when the apply agent can reason about intent, not bytes.
|
|
1350
|
-
- **Why apply agent writes to sandbox first, not directly?** Validation gate
|
|
1351
|
-
before main workspace gets touched. Test fails → main untouched. No
|
|
1352
|
-
half-applied state.
|
|
1353
|
-
- **Why wrapper does the file sync, not the apply agent?**
|
|
1354
|
-
apply agent's job is to decide *what* the new state should look like.
|
|
1355
|
-
Wrapper does mechanical "publish the result". Cleaner separation.
|
|
1356
|
-
- **Why no `releaseAllSessions` step in phase 12?** Halo's session
|
|
1357
|
-
cache is per-turn, not long-lived: `runSession`'s finally block
|
|
1358
|
-
releases the session as soon as its message turn finishes
|
|
1359
|
-
(`saveAgentState` + `Map.delete`). The next turn — channel reply,
|
|
1360
|
-
web reload, /new — runs `ensureSession` → `buildAgentInstance`,
|
|
1361
|
-
which re-reads `agent.yaml` + `INSTRUCTIONS.md` + `USER.md` from disk.
|
|
1362
|
-
So the just-applied changes pick up automatically on the next turn,
|
|
1363
|
-
for free, without anyone having to call a release hook.
|
|
1364
|
-
Currently-running turns finish on the old prompts — also the right
|
|
1365
|
-
behavior, because aborting a user mid-conversation would be a worse
|
|
1366
|
-
experience than a few seconds of "old rules apply to in-flight
|
|
1367
|
-
message". See [Session lifecycle: cold-start per turn](#session-lifecycle-cold-start-per-turn).
|
|
1368
|
-
- **Why source-snapshot.json at trigger time?** User keeps chatting; live
|
|
1369
|
-
read would see moving target. Freezing the input is the easiest correctness
|
|
1370
|
-
guarantee.
|
|
1371
|
-
- **Why no idle trigger?** User isn't there to react; pending appears later
|
|
1372
|
-
with no context recall. `/note` + pre-compact cover the live cases.
|
|
1373
|
-
- **Why no hash check at apply time?** Apply agent rebuilds the file from
|
|
1374
|
-
current state + patch intent. The patch isn't a diff that can stale —
|
|
1375
|
-
it's an instruction the agent re-applies fresh.
|
|
1376
|
-
- **Why allow evo to modify itself?** Same as software updates: blocking
|
|
1377
|
-
forever creates more harm than the rare bad self-edit. User reviews.
|
|
1378
|
-
- **Why per-process wrappers vs in-server async?** Crash isolation; can
|
|
1379
|
-
add concurrency without server thread issues; survives server restart.
|