Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
99 commits
Select commit Hold shift + click to select a range
bc47f31
fix(tui): turn off Ink's incremental renderer — it loses the frame on…
plombeer31 Sep 10, 2026
fc89805
feat(fusion): the orchestrator plans and reviews, the workers do the …
plombeer31 Sep 10, 2026
7f7d512
fix(prompt): the plan and the fan-out are one turn
plombeer31 Sep 10, 2026
756c084
fix(agent): carry the fusion gate through the step executor
plombeer31 Sep 10, 2026
2aff623
base: the orchestration loop this builds on
plombeer31 Sep 10, 2026
2b8956d
fix(telegram): stop intraword asterisks being eaten as italics
plombeer31 Sep 10, 2026
b839a19
fix(agent): retry a wholly empty native-tools completion instead of e…
plombeer31 Sep 10, 2026
33d2e96
feat(channels): a /model command for Telegram and Discord
plombeer31 Sep 10, 2026
d9c51e4
feat(llm): honour supportsTools: "strict" on the OpenAI tools payload
plombeer31 Sep 10, 2026
b3f21ac
docs(serve): say that serve runs the Telegram and Discord channels
plombeer31 Sep 10, 2026
1d062f2
fix(tracing): keep the end of a long session's trace, not the start
plombeer31 Sep 10, 2026
40ff281
feat(fusion): the machine sizes the worker pool, the model sizes each…
plombeer31 Sep 10, 2026
cc42626
fix(vision): type an image by its bytes, not its file extension
plombeer31 Sep 10, 2026
d2565a6
fix(telegram): only the lock's owner may release it
plombeer31 Sep 10, 2026
92e2738
feat(channels): tell the model who sent the message and where
plombeer31 Sep 10, 2026
9b709ad
feat(llm): opt-in strict function tools for OpenAI-compatible providers
plombeer31 Sep 10, 2026
83bf047
fix(telegram): reject emphasis flanked by whitespace or non-ASCII let…
plombeer31 Sep 10, 2026
d2fb65c
test(telegram): kill the mutations the release() guard tests missed
plombeer31 Sep 10, 2026
bf54681
fix(channels): /model must not swap the provider under another session
Sep 10, 2026
a12cab2
fix(llm): bound strict-schema nesting instead of gambling the request
plombeer31 Sep 10, 2026
37af1ff
fix(vision): stat before reading, now that the extension no longer ga…
plombeer31 Sep 10, 2026
1e3a6bb
docs(serve): correct the swarm claim and the top-level help line
plombeer31 Sep 10, 2026
fb77f8c
fix(agent): only announce a recovery there is a step left to spend
plombeer31 Sep 10, 2026
f3eb3d3
fix(channels): cut sender names on code points, and stop over-claiming
plombeer31 Sep 10, 2026
a44d4cb
docs(agents): record the stat guards in front of the vision image read
plombeer31 Sep 10, 2026
8e6fda8
fix(tracing): bound the trim's cost and sweep the temps it strands
plombeer31 Sep 10, 2026
2109a23
fix(llm): make the strict rewrite, and its undo, per tool in both dir…
plombeer31 Sep 10, 2026
05fee98
feat(fusion): two slots, either kind — pick who orchestrates and who …
plombeer31 Sep 10, 2026
30ae0ac
fix(llm): send parallel_tool_calls:false with strict tools
plombeer31 Sep 10, 2026
0fd12f1
test(llm): cover the strictTools wiring the unit tests could not see
plombeer31 Sep 10, 2026
32b2a65
fix(config): a leg pin the UI can write must not stop the app booting
plombeer31 Sep 10, 2026
9f5511f
fix(agent): report the doubled empty the last-step retry lands on
plombeer31 Sep 10, 2026
24f198b
test(agent): cover the two untested empty-run reset sites
plombeer31 Sep 10, 2026
70cd091
fix(llm): stop the strict rewrite from breaking tagged calls and eati…
plombeer31 Sep 10, 2026
85a26fd
fix(telegram): keep single-* italics alive in space-free scripts
plombeer31 Sep 10, 2026
be5f676
fix(channels): /model must not pin a model nothing reads, or hide a m…
plombeer31 Sep 10, 2026
3187a6d
fix(telegram): stop a variation selector disabling italics after emoji
plombeer31 Sep 10, 2026
b1f50ab
fix(channels): /model must answer even when the config will not parse
plombeer31 Sep 10, 2026
fb14b12
style(channels): prettier on model-command.ts
plombeer31 Sep 10, 2026
84cc402
fix(tui): the composer strip names the leg that is running, not the l…
plombeer31 Sep 10, 2026
6045322
fix(channels): clip every unbounded name, and stop hiding ambiguous c…
plombeer31 Sep 10, 2026
fed385a
fix(llm): turn parallel tool calls off whenever strict tools are on
plombeer31 Sep 10, 2026
99faa2d
feat(llm): strip value-range bounds instead of refusing the whole tool
plombeer31 Sep 10, 2026
77feaa4
docs: bring the strict-tool-schema section up to date
plombeer31 Sep 10, 2026
5dbdb5e
revert(llm): stop stripping value-range bounds, and pin why
plombeer31 Sep 10, 2026
471ede7
docs: correct the strict-tool-schema section after the revert
plombeer31 Sep 10, 2026
31aad7c
docs: scope the no-nested-object premise to the built-ins
plombeer31 Sep 10, 2026
f46e9c1
fix(tui): drop the worker-count segment from the composer's route line
plombeer31 Sep 10, 2026
e6351a3
qa: + #390 render fix
plombeer31 Sep 10, 2026
70a43d0
qa: + #392
plombeer31 Sep 10, 2026
a16cc2e
qa: + #393
plombeer31 Sep 10, 2026
5c992db
qa: + #394
plombeer31 Sep 10, 2026
82947f5
qa: + #395
plombeer31 Sep 10, 2026
06d1364
qa: + #396
plombeer31 Sep 10, 2026
22d0862
qa: + #397
plombeer31 Sep 10, 2026
25f302f
qa: + #399
plombeer31 Sep 10, 2026
f7eb5f8
qa: + #400
plombeer31 Sep 10, 2026
b1309f5
qa: + #401
plombeer31 Sep 10, 2026
853aab9
qa: + #402
plombeer31 Sep 10, 2026
b707abd
test(local-llm): stop betting that pid 777 is dead
plombeer31 Sep 10, 2026
d17a152
qa: + #404 (the download-spawn pid flake)
plombeer31 Sep 10, 2026
753b302
release: integrate the 15 open PRs (render, fusion, triage batch) (#405)
plombeer31 Sep 11, 2026
ed7e0e6
fix(fusion): make the workers do the work (#406)
plombeer31 Sep 11, 2026
ae12759
atomic-agent: release v0.6.0
plombeer31 Sep 11, 2026
e832d08
fix(memory): give the query rewriter its own fallback partition
plombeer31 Sep 13, 2026
6c633b2
fix(memory): size reflection and link-generator timeouts for hosted r…
plombeer31 Sep 13, 2026
3158a64
perf(memory): run the query rewriter once per turn
plombeer31 Sep 13, 2026
7f4491a
fix(llm): retry a sub-call without response_format when the endpoint …
plombeer31 Sep 13, 2026
10be9a4
fix(memory): cancel a sub-call's request when its timeout fires
plombeer31 Sep 13, 2026
5daa57a
fix(memory): require every key in the link and vote response schemas
plombeer31 Sep 13, 2026
1cc0b3c
fix(llm): send providerPreferences to OpenRouter as the request's pro…
plombeer31 Sep 13, 2026
5c7825d
fix(llm): mention JSON in cloud prompts that carry response_format
plombeer31 Sep 13, 2026
2d5b99f
fix(llm): when every fallback link fails, report the primary's error
plombeer31 Sep 13, 2026
d4398fa
feat(memory): warn once when memory sub-calls keep timing out or failing
plombeer31 Sep 13, 2026
05dd9e8
rc: #411 fix(memory): give the query rewriter its own fallback partition
plombeer31 Sep 13, 2026
126283c
rc: #412 fix(memory): cancel a sub-call's request when its timeout fires
plombeer31 Sep 13, 2026
0ab912f
rc: #413 fix(llm): send providerPreferences to OpenRouter as the requ…
plombeer31 Sep 13, 2026
7f7fe85
rc: #414 fix(memory): structured-output sub-calls that OpenAI strict …
plombeer31 Sep 13, 2026
0fe577e
rc: #415 fix(memory): size reflection and link-generator timeouts for…
plombeer31 Sep 13, 2026
e24a9c4
rc: #416 perf(memory): run the query rewriter once per turn
plombeer31 Sep 13, 2026
f9922b5
rc: #417 fix(llm): retry a sub-call without response_format when the …
plombeer31 Sep 13, 2026
0332629
rc: reconcile #413 providerPreferences with #417 structured-output fa…
plombeer31 Sep 13, 2026
8d980b1
rc: reconcile #414 JSON mention with #417 prompt-only retry test
plombeer31 Sep 13, 2026
cf4e372
rc: #418 fix(llm): when every fallback link fails, report the primary…
plombeer31 Sep 13, 2026
c98784f
rc: #419 feat(memory): warn once when memory sub-calls keep timing ou…
plombeer31 Sep 13, 2026
540df16
fix(memory): cap profile facts and never let the prompt clip drop pin…
plombeer31 Sep 13, 2026
88cd8b2
fix(telegram): reconnect the polling loop with backoff after a transi…
plombeer31 Sep 13, 2026
87847e5
rc: #420 fix(telegram): reconnect the polling loop with backoff after…
plombeer31 Sep 13, 2026
4bf680a
rc: #421 fix(memory): cap profile facts and never let the prompt clip…
plombeer31 Sep 13, 2026
24333a9
release: v0.6.1 integration (#411–#421) (#422)
plombeer31 Sep 13, 2026
cf2e5c8
atomic-agent: release v0.6.1
plombeer31 Sep 13, 2026
10342d3
docs: document the Composio integration in the README
sosidudku1 Sep 15, 2026
10457ad
Merge origin/main (v0.6.1) into atagdesktop
plombeer31 Sep 15, 2026
39b01c1
http: forward fusion worker progress as a named SSE frame
plombeer31 Sep 15, 2026
027d495
agent: a shell result keeps what the command printed
plombeer31 Sep 15, 2026
325714c
agent: a text-only catalogue model is not declared vision-capable
plombeer31 Sep 15, 2026
8c9a1eb
agent: the transcript records where each approval was answered
plombeer31 Sep 15, 2026
c61416b
Merge branch 'desktop/turn-fixes' into desktop/sync-main-0.6.1
plombeer31 Sep 15, 2026
9137804
http: a reply that never streamed still reaches a streaming client
plombeer31 Sep 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 92 additions & 14 deletions AGENTS.md

Large diffs are not rendered by default.

11 changes: 7 additions & 4 deletions MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ A `ProfileFact` is:

### 3.2 Prompt placement

Rendered by [src/memory/profile-renderer.ts](src/memory/profile-renderer.ts) into the `### profile` section of the variable tail (after optional `### loaded-skills`, before `### memory-index` / `### session-facts` / `### recalled`). The block is bounded by `memory.profile.maxTokens` (default `512`) with a `[truncated]` marker.
Rendered by [src/memory/profile-renderer.ts](src/memory/profile-renderer.ts) into the `### profile` section of the variable tail (after optional `### loaded-skills`, before `### memory-index` / `### session-facts` / `### recalled`). The block is bounded by `memory.profile.maxTokens` (default `512`). Facts are packed one whole line at a time, pinned facts first, and a final `… [truncated] N more profile facts not shown` line counts what was left out; when that happens the loop logs a warning and writes a `profile_clipped` trace row, once per session (issue #407).

The contextual gate is controlled by `memory.profile.contextualKeywordGate` (default `true`). When `false`, **all** facts render regardless of `pinned` — useful for debugging.

Expand Down Expand Up @@ -165,7 +165,7 @@ Caps:

- `memory.reflection.maxFactsPerCall` (default `3`) — upper bound on `SET` lines.
- `memory.reflection.maxNotesPerCall` (default `2`) — upper bound on `NOTE` lines (set to `0` to disable).
- `memory.reflection.timeoutMs` (default `10000`) — hard timeout; on timeout, nothing is written.
- `memory.reflection.timeoutMs` (default `60000`) — hard timeout; on timeout, nothing is written. Also the vote-runner's budget. It was `10000` before config v65: enough for a local `llama-server`, not for hosted reasoning models, which take 15–40 s here — sub-call timeouts must scale with provider latency.
- `memory.reflection.autoStoreNotes` (default `true`) — master switch for the `NOTE` channel.

### 5.4 Validation and observability
Expand All @@ -174,6 +174,8 @@ Parsed entries flow through the same validators as the explicit tools (`ProfileS

Metrics: `agent.memory.reflection` counter tagged by `outcome` (`ok | none | failed | aborted | timeout`) plus the `agent.memory.reflection.latency_ms` histogram. Logs: `reflection.fired`, `reflection.ok`, `reflection.none`, `reflection.aborted`, `reflection.timeout`, `reflection.failed`.

Three `timeout` / `failed` outcomes in a row (from reflection, link generation, voting or the query rewriter) are also said once per session in the chat, naming the setting to change — see AGENTS.md §"Memory sub-call health warning".

## 6. Per-turn data flow

```
Expand Down Expand Up @@ -235,8 +237,9 @@ All keys live under `memory.*` in `<stateDir>/config.json`. Defaults are in [src
| `memory.profile.enabled` | `true` | Inject `### profile` and register the three profile tools. |
| `memory.profile.maxTokens` | `512` | Hard ceiling for the rendered `### profile` block. |
| `memory.profile.contextualKeywordGate` | `true` | Hide `pinned=false` facts unless a keyword hits user message. |
| `memory.profile.maxEntries` | `500` | Cap on active **unpinned** facts; lowest-utility evicted on write. Pinned facts never count. |
| `memory.reflection.enabled` | `true` | Master switch for the async reflection runner. |
| `memory.reflection.timeoutMs` | `10000` | Hard timeout per reflection call. |
| `memory.reflection.timeoutMs` | `60000` | Hard timeout per reflection call. |
| `memory.reflection.maxFactsPerCall` | `3` | Max `SET` lines written per reflection. |
| `memory.reflection.autoStoreNotes` | `true` | Allow reflection to emit `NOTE` lines into `MemoryStore`. |
| `memory.reflection.maxNotesPerCall` | `2` | Max `NOTE` lines per reflection. `0` disables notes. |
Expand Down Expand Up @@ -289,7 +292,7 @@ Legacy: `/memory dump` still prints the active profile into the chat transcript.

- **No content dedup in `MemoryStore`.** The same `NOTE` body can be written multiple times if reflection produces it across turns. FTS5 will then return clones in `### recalled`. Mitigation: `maxNotesPerCall=2` keeps the rate low; explicit `memory.notes.forget` removes duplicates.
- **No usefulness signal in eviction.** FIFO-by-`updated_at` evicts the oldest row even if it has been recalled 100 times. A future revision could weight by recall hits.
- **Profile keys are LLM-generated.** Reflection can invent new keys (`coding_style`, `favourite_editor`, …). There is no schema check beyond length validation; horizontal growth of the profile is bounded only by `memory.profile.maxTokens` truncation.
- **Profile keys are LLM-generated.** Reflection can invent new keys (`coding_style`, `favourite_editor`, …). There is no schema check beyond length validation. Unpinned facts are capped by `memory.profile.maxEntries` (default `500`, lowest-utility evicted on write); pinned facts have no storage cap, only the `memory.profile.maxTokens` clip, which warns when it drops one (issue #407).
- **Reflection quality depends on the model.** A weak model can either skip durable facts or store trivia. The `[pinned=false; keywords=…]` syntax is a request, not a contract.
- **No embeddings, no semantic recall.** BM25 misses paraphrases. A user asking "what did I tell you about my Python testing setup?" will hit notes containing `python` and `test`, but not notes that only say "I prefer pytest for unit work".

Expand Down
2 changes: 1 addition & 1 deletion MEMORY_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ NOTE staging flyway migrations need FLYWAY_BASELINE=1 or deploy fails [tags=stag
`memory.reflection.maxFactsPerCall`).
- `NOTE` lines land in the notes store with an implicit `reflection` tag (at
most 2 per turn, `memory.reflection.maxNotesPerCall`).
- The call has a hard timeout (`memory.reflection.timeoutMs`, 10 s); on
- The call has a hard timeout (`memory.reflection.timeoutMs`, 60 s); on
timeout or parse failure nothing is written and the next turn just tries
again. At most one reflection is in flight per session.

Expand Down
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ Atomic Agent drives a full desktop tool surface. Dangerous actions are routed th
| **MCP** | Connect external MCP servers; their tools, resources, and prompts join the same registry. |
| **Providers** | Local `llama-server` by default; OpenAI-compatible, [OpenRouter](https://openrouter.ai), AI/ML API, and Gemini providers when configured, with live model catalogs and mid-session switching. Your existing **Claude Code and OpenAI Codex subscriptions** work too, driven through their own signed-in CLIs with no API key. Reasoning-only completions from reasoning models are recovered instead of failing the turn. |
| **Telegram** | Single-user remote control with owner pairing, inline approval buttons, and opt-in result reports from scheduled tasks. |
| **[Composio](https://composio.dev)** | Connect 1500+ SaaS toolkits (Gmail, Slack, Notion, Linear, and more) with OAuth handled for you. Set up from the Integrations tab; tools arrive as `mcp.composio.*` and every write to a real account stays approval-gated. |

### Memory That Grows Outside the Prompt

Expand Down Expand Up @@ -433,6 +434,8 @@ atomic-agent serve \

`POST /v1/chat/completions` maps one request to one full macro-turn: `user -> 0..N tool steps -> reply`. Atomic-specific routes expose sessions, approvals, tasks, webhooks, events, skills, config, and capabilities.

`serve` boots the same runtime the TUI does, so an enabled Telegram or Discord channel — and every enabled swarm bot that has a token — comes up in this process too. That makes `serve` the way to keep the bots answering with no TUI open; it stays in the foreground until you stop it and does not restart itself. A channel is single-instance: the first process to start it takes a lockfile in the state dir, and a second one leaves that channel down with `already running in another atomic-agent (pid N)` instead of retrying — so keep the bots in one process, this one or the TUI.

</details>

<details>
Expand Down Expand Up @@ -474,6 +477,8 @@ TELEGRAM_BOT_TOKEN=123456789:AA-your-bot-token

The TUI can store the token, start the channel, open pairing mode, and show status. Approvals arrive as inline buttons in your DM. Telegram is intentionally single-user.

The channel belongs to the runtime, not to the TUI: `atomic-agent serve` boots it exactly the same way, so the bot keeps answering with no terminal UI open. Only one process may hold a channel — it is guarded by a lockfile in the state dir — and the process that loses the race leaves that channel down with `already running in another atomic-agent (pid N)` (shown as an ordinary state, not an error, in the Integrations pane) and does not retry, so start the bot from `serve` or from the TUI, not from both.

While a turn runs, the bot keeps one live progress bubble updated in place. It is sent silently and shows step labels only, never tool output; turn it off with `"telegram": { "progressIndicator": false }`.

Send the bot a photo, document, voice note or any other file and it is saved under `~/.atomic-agent/inbox/telegram/`; the agent gets the path together with your caption and reads it with its file and vision tools. Albums arrive as one message. Telegram lets bots fetch files up to 20 MB; anything larger gets a clear "could not receive" reply.
Expand All @@ -484,6 +489,29 @@ Scheduled tasks can report back to the same chat: create a cron job with `atomic

</details>

<details>
<summary><b>Composio toolkits</b> (1500+ SaaS apps)</summary>

[Composio](https://composio.dev) is a hosted catalogue of 1500+ SaaS toolkits (Gmail, Slack, Notion, Linear, and more) that also brokers each app's OAuth, so you never register an OAuth client yourself.

Open the **Integrations** tab in the TUI and follow the setup, or drop a key into `<stateDir>/.env`:

```sh
COMPOSIO_API_KEY=ck-your-key
```

The key is the real gate: with no key the runtime opens no connection and registers no tool. Set `"composio": { "enabled": false }` in `config.json` to keep the key on disk with the toolkits off.

Under the hood this is not a new subsystem. Composio's tool router speaks Streamable HTTP MCP and authenticates with a static header, which is exactly the transport the MCP client already supports, so the agent treats it as one more MCP server. Tools land as `mcp.composio.*`.

Rather than loading 1500 toolkits into the prompt, the session exposes four meta-tools: the agent searches for a tool by use case, fetches its schema, then executes. Discovery is annotated read-only and flows without prompting; `COMPOSIO_MULTI_EXECUTE_TOOL` and `COMPOSIO_MANAGE_CONNECTIONS` are marked destructive, so every write to a real account still hits the approval gate.

Connected accounts are scoped by a random install id minted once and stored in `config.json`, never your email. Losing it means re-authorising every connected app.

Note that Composio is a hosted service: your OAuth tokens for connected apps live on Composio's infrastructure, and tool calls are executed through their servers rather than from your machine.

</details>

<details>
<summary><b>MCP client</b></summary>

Expand Down Expand Up @@ -585,6 +613,40 @@ Local models use `localModels.completionMaxTokens` (llama.cpp's `n_predict`, def

</details>

<details>
<summary><b>Models that need strict tool schemas</b> (<code>strictTools</code>)</summary>

Some models call tools reliably only when the provider constrains decoding to the tool's schema — OpenAI's **strict mode**. Set `strictTools` on the provider entry to send every function as `strict`:

```json
"llm": { "providers": [{ "id": "mercury", "kind": "openai-compatible", "strictTools": true }] }
```

Off by default, and only for OpenAI-compatible kinds (`openai-compatible`, `qwen-openai-compatible`, `openrouter`, `aimlapi`, `gemini`). `strict` is a field on each tool, so `extraBody` cannot reach it — `tools` is a reserved key that is re-applied after that merge.

With the flag on, every tool schema is rewritten into the subset strict mode accepts: objects are closed, every property is listed in `required` (an optional one becomes nullable instead of being omitted), and value-range keywords the runtime validators enforce anyway (`minItems`, `minLength`, `pattern`, `format`, `default`, …) are stripped. A handful of tools take a free-form map — `os.http.request`'s headers and body, `mcp.prompt.get`'s arguments — and those cannot be expressed strictly; they are sent unconstrained (`strict: false`) rather than silently losing their arguments.

Because optionals become nullable, a strict model sends `"pinned": null` where it used to omit the key; on these providers a top-level `null` argument is dropped again before the call runs, so tools that check for presence behave as they always did.

The flag also sends `parallel_tool_calls: false`. Strict decoding and parallel calls do not compose — OpenAI's guidance is that a parallel call "may not match supplied schemas" — so a provider asked for strict tools is asked for one call per response. `agent.maxParallelToolCalls` still governs how the runtime executes a batch.

Turn it on only for a service that implements strict mode: one that does not will reject the whole request, not just the field.

</details>

<details>
<summary><b>Choosing OpenRouter's upstream host</b> (<code>providerPreferences</code>)</summary>

OpenRouter serves most models from several hosts and picks one per request. To steer that — pin a host, forbid fallbacks, skip hosts that keep your data — set `providerPreferences` on an `openrouter` entry. It is sent unchanged as the request's `provider` routing object:

```json
"llm": { "providers": [{ "id": "openrouter", "kind": "openrouter", "providerPreferences": { "order": ["z-ai"], "allow_fallbacks": false } }] }
```

It applies to every chat completion the entry makes — turns, memory sub-calls and `vision.describe` — and other kinds ignore it. The pre-save key check does not send it: that check asks the cheapest paid model for one token, and a host pinned for your model may not serve that one. If you already set `extraBody.provider`, that keeps winning.

</details>

<details>
<summary><b>Configuration and secrets</b> (state dir, env vars, .env)</summary>

Expand Down
11 changes: 6 additions & 5 deletions eval-memory/harness/memory-profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,12 @@ export function buildMemoryConfig(
// about ~55 turns on a reflection-heavy `full_v2` profile —
// enough for interactive use but not for LoCoMo / LongMemEval
// runs that feed 30+ session prefills then ask 100+ questions.
// When the cap is hit, `trace_truncated` fires and further
// events are silently dropped (see AGENTS.md §"Traceability and
// replay"). The harness reads `assistantReply` out of the trace,
// so post-truncation turns surface as `""` even when the agent
// really did reply on stdout — see `multi-turn-driver.ts`'s
// When the cap is hit the sink drops the OLDEST events and
// leaves a `trace_truncated` marker at the seam (see AGENTS.md
// §"Traceability and replay"). The harness reads
// `assistantReply` out of the trace, so the EARLY turns of an
// over-cap run surface as `""` even when the agent really did
// reply on stdout — see `multi-turn-driver.ts`'s
// truncation-fallback for the read-side guard. Bumped here to
// 200 MiB so a full conv-44 (158 QA × 28 sessions ≈ 35 MiB of
// trace at full fidelity) leaves comfortable head-room.
Expand Down
6 changes: 4 additions & 2 deletions eval-memory/harness/postmortem-trace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,14 +187,16 @@ describe("renderPostmortem", () => {
expect(out).toContain("| 1 | max_steps | 16 | 60000 | 20000 | 0 |");
});

it("notes when trace was truncated", () => {
it("notes which end of the trace was dropped", () => {
const events: TraceEvent[] = [
turnStarted(0, 0),
turnFinished(0, 1, "reply"),
{ type: "trace_truncated", seq: 2, sessionId, ts, reason: "cap" },
];
const out = renderPostmortem(analyzeTrace(events));
expect(out).toContain("Trace truncated at seq=2");
// The marker means the events at or below this seq are gone, not
// that the trace stops here — the sink keeps writing past it.
expect(out).toContain("Trace head dropped up to seq=2");
});

it("explicitly says (none) when no problem turns", () => {
Expand Down
12 changes: 7 additions & 5 deletions eval-memory/harness/postmortem-trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,12 @@ export interface PostmortemReport {
*/
problemTurns: readonly TurnSummary[];
/**
* Last `trace_truncated` event if any was recorded, indicating the
* trace hit `tracing.trace.maxBytesPerSession` and subsequent
* events were dropped — important caveat for postmortems on long
* runs.
* Last `trace_truncated` event if any was recorded. The sink writes
* one at the seam where it dropped the OLDEST part of the file to
* stay under `tracing.trace.maxBytesPerSession`: everything at or
* below `atSeq` is missing, everything after it is intact. Important
* caveat for postmortems on long runs — the opening turns are the
* ones that are gone.
*/
traceTruncated: { reason: string; atSeq: number } | null;
}
Expand Down Expand Up @@ -219,7 +221,7 @@ export function renderPostmortem(report: PostmortemReport): string {
lines.push(`- Total turns: ${report.totalTurns}`);
if (report.traceTruncated) {
lines.push(
`- ⚠ Trace truncated at seq=${report.traceTruncated.atSeq} (reason: ${report.traceTruncated.reason})`,
`- ⚠ Trace head dropped up to seq=${report.traceTruncated.atSeq} (reason: ${report.traceTruncated.reason})`,
);
}
lines.push("");
Expand Down
Loading