Skip to content

sigils: a conversation is found by the calls it was given, not by its words, and a call keeps claude's own id - #102

Merged
adiled merged 1 commit into
mainfrom
fix/sigil-by-call-ids
Oct 28, 2026
Merged

adiled merged 1 commit into
mainfrom
fix/sigil-by-call-ids

Conversation

@adiled

@adiled adiled commented Oct 28, 2026

Copy link
Copy Markdown
Owner

Why

Testing #101 in wryme showed one conversation spread over four claude sessions in seven minutes. No claude process piled up, and nothing was lost (each new session got the whole conversation stitched in), but every request that missed began a new sigil and a cold claude session.

openai-server (and, since #100, anthropic-server and vercel-ai) found a conversation's sigil by a hash of its whole history: system prompt, every turn's words, every tool id. That is not what a sigil was meant to be (the forager picks the sid), and clients reshape history all the time. What made wryme miss:

  • on the Responses API, wryme sends claude's tool calls between rounds of a turn but not the words claude said before them, then replays the turn with them;
  • its [bookkeeping] note rides in the system prompt for one request;
  • tinker_keep drops older tool pairs, and a turn can come back squashed into one message.

What changes

A sigil is found by what the client echoes faithfully (openai-server, anthropic-server, vercel-ai)

  1. A held call's sid, then previous_response_id / conversation, as before.
  2. A line the client names: X-Hum-Sid (vercel-ai: providerOptions.hum.sid), scoped to the caller.
  3. The newest tool call id in the request that this caller was given. Ids are unique, and every client echoes them because it must to answer.
  4. The words of the last two turns, which compaction of older turns leaves alone; also tried without trailing answers, so a delivery or a late answer finds the sigil it had before them.
  5. Otherwise a new sigil.

A sigil continues only from where it last left off: the request must still hold the sigil's newest call (never a call the server ran, which a client may not echo), or match its latest tail. A copy of a conversation taken earlier begins its own. No key reads the system prompt.

claude-cli tells a changed system prompt at the head of the turn it changes in, and reads what a session was told from its transcript when a cell is raised on it. Before, only a new session was told, so a changed prompt reached claude only by starting a new session.

A call keeps the id its cell gave it. The nest asks the nurse, Nurse::call_id (default none), and knows nothing of any cell. claude-cli reads Claude Code's _meta["claudecode/toolUseId"]. A caller, the forager and claude's transcript now name a call alike. Held tool results are matched by call and answer, then by tool and answer; a late answer to a call claude already holds an answer for is stitched on as a call of its own, since a call takes one answer.

Testing

  • hum-nest: 34 pass, tools/speech.py clean, clippy clean. New: a call keeps the id its cell gave it.
  • claude-cli: all pass. New: the call id read from _meta; a changed system prompt told, an unchanged one not; a transcript says what it was told last; a late answer to a held call stitched as its own call.
  • openai-server 69, anthropic-server 29, vercel-ai 22, end to end included. Sigils tests: dropped older pairs, a squashed turn, mid-turn words missing then present, a changed system prompt, a made-up delivery, a tool-less tail, reordered calls, a server-run last call with mcp_calls dropped, all keep the sigil; strangers, a growing sigil, and copies taken earlier (with and without tools) do not; X-Hum-Sid names a line per caller.
  • Live (HUM_LIVE_CLAUDE=1, claude-haiku-4-5): 20/20, new: a changed system prompt obeyed on the same sigil (nurse.cell.reused); X-Hum-Sid carrying a codeword across two unrelated requests; every caller tool call arriving as toolu_….

Known limits

  • A client that edits an older message keeps its session, so claude remembers the unedited version.
  • A copy of a tool-less conversation, taken after its latest turn, shares the original's sigil.
  • A compaction summary sent as a user message right before the last two turns changes the tail; as a system message it does not.

… words, and a call keeps claude's own id

The OpenAI, Anthropic and AI SDK front doors found a conversation's sigil
by a hash of its whole history. Clients compact and reshape history all
the time: wryme drops older tool pairs, squashes a turn into one message,
on the Responses API drops claude's words between tool rounds and replays
them later, and puts a one-off note in its system prompt. Each made the
hash miss, so the request began a new sigil and a new claude session,
the whole conversation stitched in again.

A request now finds its sid by the line its client names, X-Hum-Sid (on
the AI SDK, providerOptions.hum.sid), then by the newest tool call id it
holds that its caller was given, then by the words of its last two turns.
Ids are unique and every client echoes them, since it must to answer;
compaction leaves the last turns alone. A sigil continues only from where
it last left off: from its newest call, which the request must still
hold, and never one the server ran, which a client may not echo; or from
its latest tail. A copy of a conversation taken earlier begins its own.
No key reads the system prompt.

claude-cli now tells claude a changed system prompt at the head of the
turn it changes in, reading what a session was told from its transcript,
since claude takes no system prompt but the one it is told.

A call keeps the id its cell gave it. The nest asks the nurse for it,
Nurse::call_id, knowing nothing of any cell; claude-cli reads Claude
Code's tool use id from the call's _meta. A caller, the forager and
claude's transcript now name a call alike, so a held transcript's tool
results are matched by call and answer, then by tool and answer, and a
late answer to a call claude already holds an answer for is stitched on
as a call of its own.
@adiled
adiled merged commit a10636e into main Oct 28, 2026
7 checks passed
adiled added a commit that referenced this pull request Oct 28, 2026
…#103)

## Why

Watching wryme on main after #102: each delivery (a finished job or another window's note) restarted claude twice, both times resuming the same session. The first restart is needed: the late tool result has to be stitched into the transcript before claude loads it. The second, on the next user message, was not.

## What changes

A cell raised for a prompt with no words grows the graft's last bloom on; it adds no bloom. The nest counted its wilt as a new bloom, so the next prompt's graft, which holds the same number of blooms, looked like it carried a bloom the cell did not grow, and a fresh cell was raised. Such a cell now starts its count one bloom short of its graft, so its wilt brings it level and the next prompt is fed to it.

## Testing
- hum-nest: 35 pass. New test: a cell raised for a delivery, once it wilts, is fed the next worded prompt on its sigil with no second raise. It fails without the fix.
- `tools/speech.py` clean, clippy clean; claude-cli's tests pass against it.
adiled added a commit to adiled/wryme that referenced this pull request Oct 28, 2026
…till, a turn's words reach its next round, and wryme is held to the specs it speaks (#69)

## Why

Running wryme against hum's claude-cli models showed four wryme-side problems:

1. **Deliveries were lost when a turn failed.** A finished job's result or a window's note is recorded on the turn's assistant row as a tool call and result (`due_calls`). If the request failed, `finish_streaming` treated the row as an empty reply and deleted it. The item had already been claimed from its queue, so it was gone for good.
2. **The system prompt changed every minute.** Open book pages render `Last inked: 7m ago` into the system prompt on every request, so no two requests had the same system prompt. Prompt caches missed, and hum started a new claude session each turn.
3. **Responses protocol: pages said twice, prod dropped.** `instructions` was the first system message, which is the first page when the station has no prompt of its own. The pages were then added again as input items, so each page appeared twice, and the bookkeeping prod (already taken by `api_messages`) was dropped. In a warm window, the page items were also stored again on every turn.
4. **Responses, full window: nothing finished was ever carried.** The `response.output_item.done` handler read `output_item`, a field no server sends (the API's is `item`, which the `output_item.added` handler already reads). Reasoning, `mcp_call` and hosted items were never carried into the next round, and a call the server ran never showed its result. The tests passed only because their fixtures used the same wrong name. The words the model said before a tool call weren't carried at all, so within a turn a stateless endpoint never saw them and the next turn replayed them; to hum the conversation looked edited, and it began a new claude session. Argument deltas read `output_item_id` alone, where the API sends `item_id`.

5. **Fixtures agreed with bugs.** No test checked wryme against the specs it speaks, so problem 4 hid behind fixtures with the same wrong field name. An audit against both specs turned up more (below).

## What changes
- `finish_streaming` keeps a turn's rows that hold tool events, so a delivered result or note stays in history after a failed turn. A turn with nothing at all is still dropped.
- The preamble shows when a page was last inked as a local time (`2026-10-28 20:31`), so it stays the same until the page is inked again. The `book open` tool result still shows the age.
- Responses: `instructions` is every system message joined (the station prompt, open pages, prod), the same as the chat protocol's system prompt. Pages are no longer added again as input items.
- Responses: a finished item is read from `item`. An assistant message is carried into the next round like reasoning, so the model's words before a call stay in the turn. Argument deltas are matched by `item_id`.

- **Specs, pinned, in `spec/`:** OpenAI's OpenAPI (openai/openai-openapi @ 26a2991) cut to the 392 schemas wryme's clients reach, and Open Responses (openresponses/openresponses @ 8907bc3, 2026-04-24) whole. `spec/pull` fetches them again.
- **Tests held to them:** `src/spec.rs` (test-only, `jsonschema` as a native dev-dependency) checks every fixture event, every chat chunk and every request wryme builds (full and warm windows, tools, history, deliveries) against both specs. Fixtures are built by constructors that check themselves; anything beyond both specs is marked `beyond_spec`. The checker's own tests prove it rejects `output_item`, `output_item_id`, a missing `sequence_number` and `summary: "none"`.
- **What the audit fixed:**
  | Spec says | wryme did | Now |
  |---|---|---|
  | `reasoning.summary` is `auto`/`concise`/`detailed` | Hush sent `"none"` | Hush asks for no summary |
  | `max_tool_calls` caps built-in tools only, and is ≥ 1 in Open Responses | sent `0` at the tool depth limit, so function calls kept coming | `tool_choice: "none"` |
  | message items have `type: "message"` (Open Responses) | bare `{role, content}` | typed |
  | with `store: false`, a reasoning item needs `encrypted_content`, and a server id can't be resolved | would carry both as received | reasoning carried only with its encrypted content; words as a plain assistant message |
  | MCP argument events are `response.mcp_call_arguments.*` | listened for `mcp_call.arguments.*` | spec names |
  | Open Responses streams raw reasoning as `response.reasoning.delta` | ignored | read as Heart |
  | a failed `mcp_call`'s `error` is an object | looked up a field that isn't there | shown as its words |
  | a server-run function call is reported under its `call_id` | under its item id | `call_id` |
- **Kept on purpose:** the Open Responses effort enum lacks `minimal` and `max` (Swift and Max stations); Chat `reasoning_content`, `multi_agent` items, and function calls the provider ran are extensions beyond both specs.

## Testing
- `cargo test`: 179 pass, twice. New tests: `a_failed_turn_keeps_what_it_delivered` (fails without the fix), `an_open_page_is_said_the_same_until_inked_again`, `every_system_message_is_said_once_as_instructions`, `the_words_said_before_a_call_are_carried_into_the_next_round`, and `arguments_stream_in_by_their_item_id`. Every fixture is now built by constructors checked against the specs.
- `cargo clippy --all-targets` and `cargo check --target wasm32-unknown-unknown` are clean.

## Not included
- A turn's steps are still kept as one message (all words, then all calls). hum no longer depends on the shape (adiled/hum#102 finds a conversation by its call ids), but other providers still see the final answer ahead of the tool calls. That's a possible follow-up.
- The bookkeeping prod still changes the system prompt on the turn it appears. hum now keeps the session across that and tells claude the changed prompt (adiled/hum#102).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant