From 9c64a7d4462a11674e2dea481b037b5f5d9c62fc Mon Sep 17 00:00:00 2001 From: t Date: Tue, 2 Jun 2026 09:02:49 -0600 Subject: system prompt building and logging --- agent/SYSTEM.md | 3 + docs/archive/system-prompt.md | 299 +++++++++++++++++ docs/system-prompt.md | 289 ----------------- libpanto/src/anthropic_messages_json.zig | 81 ++++- libpanto/src/conversation.zig | 162 ++++++++- libpanto/src/openai_chat_json.zig | 79 +++++ libpanto/src/session.zig | 72 ++++ libpanto/src/session_manager.zig | 16 +- src/main.zig | 58 ++-- src/system_prompt.zig | 541 +++++++++++++++++++++++++++++++ 10 files changed, 1276 insertions(+), 324 deletions(-) create mode 100644 agent/SYSTEM.md create mode 100644 docs/archive/system-prompt.md delete mode 100644 docs/system-prompt.md create mode 100644 src/system_prompt.zig diff --git a/agent/SYSTEM.md b/agent/SYSTEM.md new file mode 100644 index 0000000..b643ddf --- /dev/null +++ b/agent/SYSTEM.md @@ -0,0 +1,3 @@ +You are an expert coding assistant inside panto, a coding agent. You help +with software engineering tasks, working directly in the user's project +through the tools available to you. Keep your responses concise. diff --git a/docs/archive/system-prompt.md b/docs/archive/system-prompt.md new file mode 100644 index 0000000..e44f953 --- /dev/null +++ b/docs/archive/system-prompt.md @@ -0,0 +1,299 @@ +# System Prompt + +Status: implemented (libpanto data model + serializers + session store + +CLI sourcing & resume reconciliation). See `src/system_prompt.zig` and the +seeding/reconciliation wiring in `src/main.zig`. + +This document specifies first-class support for the system prompt across +libpanto (the conversation + session-store data model and provider +serializers) and the `panto` CLI (file-based sourcing across config +layers, plus reconciliation on resume). + +Today there is no system-prompt API at all: the CLI hard-codes +`"You are a helpful assistant."` and seeds it as a single `.system`-role +message (`src/main.zig`). This design replaces that with a model where the +system prompt is conversation data that can **change over the life of a +conversation** and is **faithfully reconstructable at any earlier point in +time** (a prerequisite for a future pi-style `/tree` command). + +## Principles + +- **The system prompt is conversation state, not config state.** It lives + in the conversation and the session log, never in the per-turn `Config` + snapshot. The agent does not own or inject it. +- **It changes over time, and the log records that change.** Every + mutation is its own positioned log entry. Truncating the message list at + position _N_ and re-deriving the prompt yields exactly the prompt as it + was at _N_. This is what makes `/tree` faithful. +- **Append-only history, replace-capable semantics.** Mutations only ever + *append* log entries. A mutation may carry `mode = replace`, which means + "from here on, discard all prior system text," but it does so by adding a + new entry — it never rewrites history. +- **Convention over configuration in the CLI.** No new TOML keys. The + prompt is sourced from `SYSTEM.md` / `APPEND_SYSTEM.md` files discovered + across the existing config layers. + +## Part 1 — libpanto / session-store data model + +### 1.1 A `.System` content block with a mode + +System prompts remain `.system`-**role** messages. What changes is the +*content block*: instead of a plain `.Text` block, a system message +carries a new `System` content-block variant that records its mode. + +```zig +pub const SystemBlock = struct { + text: TextualBlock = .empty, + mode: SystemMode = .append, + + pub fn deinit(self: *SystemBlock, alloc: Allocator) void { + self.text.deinit(alloc); + } +}; + +pub const SystemMode = enum { append, replace }; + +pub const ContentBlock = union(enum) { + Text: TextualBlock, + Thinking: ThinkingBlock, + ToolUse: ToolUseBlock, + ToolResult: ToolResultBlock, + System: SystemBlock, // new + + // deinit gains a `.System => |*b| b.deinit(alloc)` arm. +}; +``` + +Rationale for a distinct block (rather than a `mode` field hung off +`Message`): the mode is only meaningful for system content. Putting it on +`Message` would leave a meaningless field on every user/assistant/tool +message. Keeping the `.system` *role* (option (a) from design discussion) +keeps the blast radius minimal: serializers and the session manager +already filter on `role == .system`; only the block payload grows. + +### 1.2 Conversation methods + +```zig +/// Append a system message in `append` mode. Adds to the effective +/// system prompt. (Back-compatible: same external behavior as today.) +pub fn addSystemMessage(self: *Conversation, text: []const u8) !void; + +/// Append a system message in `replace` mode. When the effective prompt +/// is rebuilt, this discards all prior system text and starts fresh. +pub fn replaceSystemMessage(self: *Conversation, text: []const u8) !void; +``` + +Both append a `.system`-role message whose single content block is a +`.System` block; they differ only in the recorded `mode`. Both are +available to extensions, so an extension can grow *or* wholesale replace +the system prompt at any point mid-conversation. + +### 1.3 Deriving the effective system prompt + +A single shared rule governs both provider serializers and session +rebuild. Walk the conversation messages in order; for each `.system` +message's `.System` block: + +- `append`: add the block's text to the running list of effective system + blocks. +- `replace`: **clear the running list**, then add this block's text. + +The result is an ordered list of surviving system-text blocks. "Replace +means replace" — it wipes everything collected so far, regardless of how +those earlier blocks were joined. + +Because each mutation is a positioned entry, this same walk over a +*prefix* of the messages reconstructs the prompt as of that point — the +`/tree` property. + +### 1.4 Provider serialization + +**Anthropic (`anthropic_messages_json.zig`).** The wire format requires a +single top-level `system` string. `collectSystemPrompt` already walks all +`.system` messages; update it to: + +1. Apply the append/replace derivation above. +2. For each surviving block, **strip trailing newlines**. +3. **Join with `\n\n---\n\n`** (double-newline / horizontal rule / + double-newline), instead of the current single `\n`. + +Emit the joined string as the top-level `system` field (omit the field +entirely if empty, as today). + +**OpenAI (`openai_chat_json.zig`).** Today `.system` messages are emitted +positionally as ordinary messages. Change to: + +1. Apply the append/replace derivation. +2. Emit the surviving system blocks as **separate leading `system`-role + messages**, in order, before any non-system message. +3. Emit all non-system messages in their original order afterward. + +Do **not** concatenate OpenAI system messages into one. Keeping them as +separate, individually-positioned messages preserves block-level +addressability (again, for `/tree`-style truncation). The double-rule +join is an Anthropic-only concession to its single-string wire format. + +### 1.5 Session log format + +`system`-role log objects gain an optional `mode` field: + +```json +{"...","message":{"role":"system","mode":"replace","content":[{"type":"text","text":"..."}]}} +``` + +- `mode` is `"append"` or `"replace"`. +- **Absent `mode` defaults to `"append"`** — existing logs read back + identically, no migration needed. + +On write, the session manager records the block's mode. On +`rebuildConversation`, it reads `mode` (defaulting to `append`) and +reconstructs the corresponding `.System` block. + +## Part 2 — `panto` CLI: sourcing & reconciliation + +### 2.1 File discovery across layers + +The system prompt is sourced from files discovered across the same three +layers the CLI already uses for config / extensions / tools, in +precedence order **base → user → project** (project highest): + +| Layer | Directory | +|---------|------------------------------------------------------| +| base | `${XDG_DATA_HOME:-$HOME/.local/share}/panto/` | +| user | `${XDG_CONFIG_HOME:-$HOME/.config}/panto/` | +| project | `./.panto/` | + +In each layer we look for two files: + +- **`SYSTEM.md`** — the base/seed system prompt. +- **`APPEND_SYSTEM.md`** — an additional appended system block. + +There is **no TOML key** for the system prompt. Convention only. + +### 2.2 Resolution rules + +- **`SYSTEM.md`:** the **highest layer present wins** (whole-file + override, matching how scalar config values already override across + layers). The winning file's content becomes the **seed** system block. +- **`APPEND_SYSTEM.md`:** **every** layer's file is respected; each + becomes its own appended system block. +- **Built-in default:** if no `SYSTEM.md` exists at any layer, fall back + to a built-in default seed. (The current default, + `"You are a helpful assistant."`, needs a rewrite — tracked separately.) + +### 2.3 Ordering of the resolved blocks + +The resolved sequence of system blocks for a fresh session is: + +1. **Seed** (`SYSTEM.md` winner, or built-in default) — emitted first, so + it reads earliest / highest-salience. +2. **Appends**, collected base → user → project, then **emitted in + reversed order: project → user → base.** + +Reversing the appends places the project-layer append earliest among the +appends (right after the seed). The working hypothesis is that LLMs weight +earlier prompt text more heavily, so the most-specific (project) layer +should lead. This is a *defensible default heuristic*, not a proven +optimum — primacy vs. recency weighting in long context is an open +empirical question. What we guarantee is **determinism and consistency**; +the ordering can be retuned later as a localized change. + +> Example: base has `SYSTEM.md` + `APPEND_SYSTEM.md`; user has +> `APPEND_SYSTEM.md`; project has `SYSTEM.md` + `APPEND_SYSTEM.md`. +> Resolved blocks, in emission order: +> 1. seed = project `SYSTEM.md` (highest layer wins) +> 2. project `APPEND_SYSTEM.md` +> 3. user `APPEND_SYSTEM.md` +> 4. base `APPEND_SYSTEM.md` + +### 2.4 Fresh session + +Seed the conversation with the resolved blocks (§2.3) and append matching +entries to the session log: + +- Seed → `addSystemMessage` (an `append`-mode block; nothing precedes it + so it is effectively the foundation). +- Each append → `addSystemMessage` in the resolved order. + +### 2.5 Resume reconciliation + +`SYSTEM.md` / `APPEND_SYSTEM.md` are moving targets: they can change +between the session that created a log and a later resume. On resume we +re-consult configuration and, if it has changed, append new log entries so +the conversation continues under the updated prompt — **without rewriting +history** (preserving `/tree` faithfulness) and **without clobbering +extension-authored prompt edits**. + +**Positional comparison (no provenance tag).** Resolve the current config +blocks per §2.3: this yields an ordered list — `[seed, append₁, …, +appendₙ]` of length `K`. Compare these, **by position and exact text**, +against the **current effective config window**: the last `replace`-mode +`.System` block in the rebuilt conversation, plus every `.System` block +after it (i.e. the system blocks from the most recent re-seed onward). + +> **Why not the *first* `K` blocks?** Reconciliation appends a +> `replace + N×append` sequence to the log. A second resume must compare +> against *that* sequence, not the session's original seed — otherwise a +> no-op resume after a prior reconciliation would mismatch the stale +> original blocks and needlessly re-replace on every load. Anchoring to +> the latest `replace` makes a no-op resume a true no-op. (A fresh +> session has no `replace` block; the window is then the leading system +> blocks from the start — the original seed sequence, the intended +> behavior for a first resume.) + +- **All `K` match (text-equal, in order):** config is unchanged relative + to the current effective prompt. Do nothing. +- **Any difference (any of the `K` positions differs, or the window has a + different number of blocks):** append a fresh reconciliation sequence to + the log: + 1. one `replace`-mode entry carrying the current seed text, then + 2. one `append`-mode entry per current append block, in resolved order. + + Because the leading entry is `replace`, the derivation (§1.3) discards + the stale config blocks *and* any earlier system text, then re-applies + the current config prompt. Extension-authored system edits that occurred + *after* the original config blocks are **also** discarded by the + `replace` — this is the accepted semantics: a config change re-seeds the + prompt wholesale. (Extensions that need to survive a config re-seed can + re-apply their edit on the next turn.) + +Comparing the current effective window positionally is deliberately simple +and needs no extra log surface (no provenance/source field). A provenance +flag would buy the freedom to, e.g., change append ordering without +triggering reconciliation, but that flexibility is explicitly **not** +wanted right now — consistent ordering is the contract. + +> Note: this means changing `APPEND_SYSTEM.md` ordering or content always +> triggers a full `replace + N×append` on the next resume. That is +> intended. + +## Out of scope (tracked separately) + +- Rewriting the built-in default system prompt. +- CLI flags (`--system-prompt`, etc.). Convention-first; flags can be + layered on later if a one-off override is wanted. +- The `/tree` command itself — this design only guarantees the data model + can support it. + +## Implementation order + +1. ✅ **libpanto data model:** `SystemMode`, `SystemBlock`, `ContentBlock` + arm + `deinit`; `addSystemMessage` (mode-aware) + `replaceSystemMessage`; + shared `effectiveSystemBlocks` derivation helper. +2. ✅ **Serializers:** OpenAI leading-system hoist (separate messages); + Anthropic strip-trailing-newlines + `\n\n---\n\n` join. Both via the + shared derivation. Tests added. +3. ✅ **Session store:** optional `mode` field on system entries (read + + write, default `append`); rebuild reconstructs `.System` blocks. Tests. +4. ✅ **CLI sourcing** (`src/system_prompt.zig`): discover `SYSTEM.md` / + `APPEND_SYSTEM.md` across the three layers (base = `$PANTO_HOME/agent`, + user = `${XDG_CONFIG_HOME:-$HOME/.config}/panto`, project = `./.panto`); + resolve + order per §2.2–§2.3; seed fresh sessions. +5. ✅ **CLI resume reconciliation:** positional comparison against the + current effective config window (anchored to the latest `replace`); + append `replace + N×append` on any difference. Tests. + +> Note: the base layer directory is `$PANTO_HOME/agent` (i.e. +> `$XDG_DATA_HOME/panto/agent`), matching the convention used for extension +> and tool discovery, rather than the bare `$XDG_DATA_HOME/panto/` sketched +> in §2.1. diff --git a/docs/system-prompt.md b/docs/system-prompt.md deleted file mode 100644 index df296f6..0000000 --- a/docs/system-prompt.md +++ /dev/null @@ -1,289 +0,0 @@ -# System Prompt - -Status: design / not yet implemented. - -This document specifies first-class support for the system prompt across -libpanto (the conversation + session-store data model and provider -serializers) and the `panto` CLI (file-based sourcing across config -layers, plus reconciliation on resume). - -Today there is no system-prompt API at all: the CLI hard-codes -`"You are a helpful assistant."` and seeds it as a single `.system`-role -message (`src/main.zig`). This design replaces that with a model where the -system prompt is conversation data that can **change over the life of a -conversation** and is **faithfully reconstructable at any earlier point in -time** (a prerequisite for a future pi-style `/tree` command). - -## Principles - -- **The system prompt is conversation state, not config state.** It lives - in the conversation and the session log, never in the per-turn `Config` - snapshot. The agent does not own or inject it. -- **It changes over time, and the log records that change.** Every - mutation is its own positioned log entry. Truncating the message list at - position _N_ and re-deriving the prompt yields exactly the prompt as it - was at _N_. This is what makes `/tree` faithful. -- **Append-only history, replace-capable semantics.** Mutations only ever - *append* log entries. A mutation may carry `mode = replace`, which means - "from here on, discard all prior system text," but it does so by adding a - new entry — it never rewrites history. -- **Convention over configuration in the CLI.** No new TOML keys. The - prompt is sourced from `SYSTEM.md` / `APPEND_SYSTEM.md` files discovered - across the existing config layers. - -## Part 1 — libpanto / session-store data model - -### 1.1 A `.System` content block with a mode - -System prompts remain `.system`-**role** messages. What changes is the -*content block*: instead of a plain `.Text` block, a system message -carries a new `System` content-block variant that records its mode. - -```zig -pub const SystemBlock = struct { - text: TextualBlock = .empty, - mode: SystemMode = .append, - - pub fn deinit(self: *SystemBlock, alloc: Allocator) void { - self.text.deinit(alloc); - } -}; - -pub const SystemMode = enum { append, replace }; - -pub const ContentBlock = union(enum) { - Text: TextualBlock, - Thinking: ThinkingBlock, - ToolUse: ToolUseBlock, - ToolResult: ToolResultBlock, - System: SystemBlock, // new - - // deinit gains a `.System => |*b| b.deinit(alloc)` arm. -}; -``` - -Rationale for a distinct block (rather than a `mode` field hung off -`Message`): the mode is only meaningful for system content. Putting it on -`Message` would leave a meaningless field on every user/assistant/tool -message. Keeping the `.system` *role* (option (a) from design discussion) -keeps the blast radius minimal: serializers and the session manager -already filter on `role == .system`; only the block payload grows. - -### 1.2 Conversation methods - -```zig -/// Append a system message in `append` mode. Adds to the effective -/// system prompt. (Back-compatible: same external behavior as today.) -pub fn addSystemMessage(self: *Conversation, text: []const u8) !void; - -/// Append a system message in `replace` mode. When the effective prompt -/// is rebuilt, this discards all prior system text and starts fresh. -pub fn replaceSystemMessage(self: *Conversation, text: []const u8) !void; -``` - -Both append a `.system`-role message whose single content block is a -`.System` block; they differ only in the recorded `mode`. Both are -available to extensions, so an extension can grow *or* wholesale replace -the system prompt at any point mid-conversation. - -### 1.3 Deriving the effective system prompt - -A single shared rule governs both provider serializers and session -rebuild. Walk the conversation messages in order; for each `.system` -message's `.System` block: - -- `append`: add the block's text to the running list of effective system - blocks. -- `replace`: **clear the running list**, then add this block's text. - -The result is an ordered list of surviving system-text blocks. "Replace -means replace" — it wipes everything collected so far, regardless of how -those earlier blocks were joined. - -Because each mutation is a positioned entry, this same walk over a -*prefix* of the messages reconstructs the prompt as of that point — the -`/tree` property. - -### 1.4 Provider serialization - -**Anthropic (`anthropic_messages_json.zig`).** The wire format requires a -single top-level `system` string. `collectSystemPrompt` already walks all -`.system` messages; update it to: - -1. Apply the append/replace derivation above. -2. For each surviving block, **strip trailing newlines**. -3. **Join with `\n\n---\n\n`** (double-newline / horizontal rule / - double-newline), instead of the current single `\n`. - -Emit the joined string as the top-level `system` field (omit the field -entirely if empty, as today). - -**OpenAI (`openai_chat_json.zig`).** Today `.system` messages are emitted -positionally as ordinary messages. Change to: - -1. Apply the append/replace derivation. -2. Emit the surviving system blocks as **separate leading `system`-role - messages**, in order, before any non-system message. -3. Emit all non-system messages in their original order afterward. - -Do **not** concatenate OpenAI system messages into one. Keeping them as -separate, individually-positioned messages preserves block-level -addressability (again, for `/tree`-style truncation). The double-rule -join is an Anthropic-only concession to its single-string wire format. - -### 1.5 Session log format - -`system`-role log objects gain an optional `mode` field: - -```json -{"...","message":{"role":"system","mode":"replace","content":[{"type":"text","text":"..."}]}} -``` - -- `mode` is `"append"` or `"replace"`. -- **Absent `mode` defaults to `"append"`** — existing logs read back - identically, no migration needed. - -On write, the session manager records the block's mode. On -`rebuildConversation`, it reads `mode` (defaulting to `append`) and -reconstructs the corresponding `.System` block. - -## Part 2 — `panto` CLI: sourcing & reconciliation - -### 2.1 File discovery across layers - -The system prompt is sourced from files discovered across the same three -layers the CLI already uses for config / extensions / tools, in -precedence order **base → user → project** (project highest): - -| Layer | Directory | -|---------|------------------------------------------------------| -| base | `${XDG_DATA_HOME:-$HOME/.local/share}/panto/` | -| user | `${XDG_CONFIG_HOME:-$HOME/.config}/panto/` | -| project | `./.panto/` | - -In each layer we look for two files: - -- **`SYSTEM.md`** — the base/seed system prompt. -- **`APPEND_SYSTEM.md`** — an additional appended system block. - -There is **no TOML key** for the system prompt. Convention only. - -### 2.2 Resolution rules - -- **`SYSTEM.md`:** the **highest layer present wins** (whole-file - override, matching how scalar config values already override across - layers). The winning file's content becomes the **seed** system block. -- **`APPEND_SYSTEM.md`:** **every** layer's file is respected; each - becomes its own appended system block. -- **Built-in default:** if no `SYSTEM.md` exists at any layer, fall back - to a built-in default seed. (The current default, - `"You are a helpful assistant."`, needs a rewrite — tracked separately.) - -### 2.3 Ordering of the resolved blocks - -The resolved sequence of system blocks for a fresh session is: - -1. **Seed** (`SYSTEM.md` winner, or built-in default) — emitted first, so - it reads earliest / highest-salience. -2. **Appends**, collected base → user → project, then **emitted in - reversed order: project → user → base.** - -Reversing the appends places the project-layer append earliest among the -appends (right after the seed). The working hypothesis is that LLMs weight -earlier prompt text more heavily, so the most-specific (project) layer -should lead. This is a *defensible default heuristic*, not a proven -optimum — primacy vs. recency weighting in long context is an open -empirical question. What we guarantee is **determinism and consistency**; -the ordering can be retuned later as a localized change. - -> Example: base has `SYSTEM.md` + `APPEND_SYSTEM.md`; user has -> `APPEND_SYSTEM.md`; project has `SYSTEM.md` + `APPEND_SYSTEM.md`. -> Resolved blocks, in emission order: -> 1. seed = project `SYSTEM.md` (highest layer wins) -> 2. project `APPEND_SYSTEM.md` -> 3. user `APPEND_SYSTEM.md` -> 4. base `APPEND_SYSTEM.md` - -### 2.4 Fresh session - -Seed the conversation with the resolved blocks (§2.3) and append matching -entries to the session log: - -- Seed → `addSystemMessage` (an `append`-mode block; nothing precedes it - so it is effectively the foundation). -- Each append → `addSystemMessage` in the resolved order. - -### 2.5 Resume reconciliation - -`SYSTEM.md` / `APPEND_SYSTEM.md` are moving targets: they can change -between the session that created a log and a later resume. On resume we -re-consult configuration and, if it has changed, append new log entries so -the conversation continues under the updated prompt — **without rewriting -history** (preserving `/tree` faithfulness) and **without clobbering -extension-authored prompt edits**. - -**Positional comparison (no provenance tag).** Resolve the current config -blocks per §2.3: this yields an ordered list — `[seed, append₁, …, -appendₙ]` of length `K`. Compare these, **by position and exact text**, -against the **current effective config window**: the last `replace`-mode -`.System` block in the rebuilt conversation, plus every `.System` block -after it (i.e. the system blocks from the most recent re-seed onward). - -> **Why not the *first* `K` blocks?** Reconciliation appends a -> `replace + N×append` sequence to the log. A second resume must compare -> against *that* sequence, not the session's original seed — otherwise a -> no-op resume after a prior reconciliation would mismatch the stale -> original blocks and needlessly re-replace on every load. Anchoring to -> the latest `replace` makes a no-op resume a true no-op. (A fresh -> session has no `replace` block; the window is then the leading system -> blocks from the start — the original seed sequence, the intended -> behavior for a first resume.) - -- **All `K` match (text-equal, in order):** config is unchanged relative - to the current effective prompt. Do nothing. -- **Any difference (any of the `K` positions differs, or the window has a - different number of blocks):** append a fresh reconciliation sequence to - the log: - 1. one `replace`-mode entry carrying the current seed text, then - 2. one `append`-mode entry per current append block, in resolved order. - - Because the leading entry is `replace`, the derivation (§1.3) discards - the stale config blocks *and* any earlier system text, then re-applies - the current config prompt. Extension-authored system edits that occurred - *after* the original config blocks are **also** discarded by the - `replace` — this is the accepted semantics: a config change re-seeds the - prompt wholesale. (Extensions that need to survive a config re-seed can - re-apply their edit on the next turn.) - -Comparing the current effective window positionally is deliberately simple -and needs no extra log surface (no provenance/source field). A provenance -flag would buy the freedom to, e.g., change append ordering without -triggering reconciliation, but that flexibility is explicitly **not** -wanted right now — consistent ordering is the contract. - -> Note: this means changing `APPEND_SYSTEM.md` ordering or content always -> triggers a full `replace + N×append` on the next resume. That is -> intended. - -## Out of scope (tracked separately) - -- Rewriting the built-in default system prompt. -- CLI flags (`--system-prompt`, etc.). Convention-first; flags can be - layered on later if a one-off override is wanted. -- The `/tree` command itself — this design only guarantees the data model - can support it. - -## Implementation order - -1. **libpanto data model:** `SystemMode`, `SystemBlock`, `ContentBlock` - arm + `deinit`; `addSystemMessage` (mode-aware) + `replaceSystemMessage`; - shared append/replace derivation helper. -2. **Serializers:** OpenAI leading-system hoist (separate messages); - Anthropic strip-trailing-newlines + `\n\n---\n\n` join. Both via the - shared derivation. Add/extend tests. -3. **Session store:** optional `mode` field on system entries (read + - write, default `append`); rebuild reconstructs `.System` blocks. Tests. -4. **CLI sourcing:** discover `SYSTEM.md` / `APPEND_SYSTEM.md` across the - three layers; resolve + order per §2.2–§2.3; seed fresh sessions. -5. **CLI resume reconciliation:** positional first-`K` comparison; append - `replace + N×append` on any difference. Tests. diff --git a/libpanto/src/anthropic_messages_json.zig b/libpanto/src/anthropic_messages_json.zig index 5b65c2b..f8bfc2e 100644 --- a/libpanto/src/anthropic_messages_json.zig +++ b/libpanto/src/anthropic_messages_json.zig @@ -111,20 +111,26 @@ fn writeRawJson(s: *std.json.Stringify, raw: []const u8) !void { try s.write(parsed.value); } +/// Build the top-level Anthropic `system` string. Applies the shared +/// append/replace derivation (see `conversation.effectiveSystemBlocks`), +/// strips trailing newlines from each surviving block, and joins them with +/// a horizontal rule (`\n\n---\n\n`). Anthropic's wire format requires a +/// single string, so this rule is its concession to that constraint. fn collectSystemPrompt( conv: *const conversation.Conversation, out: *std.ArrayList(u8), allocator: Allocator, ) !void { + var blocks = try conversation.effectiveSystemBlocks(allocator, conv.messages.items); + defer blocks.deinit(allocator); + + const sep = "\n\n---\n\n"; var first = true; - for (conv.messages.items) |msg| { - if (msg.role != .system) continue; - for (msg.content.items) |block| { - if (block != .Text) continue; - if (!first) try out.append(allocator, '\n'); - try out.appendSlice(allocator, block.Text.items); - first = false; - } + for (blocks.items) |text| { + const trimmed = std.mem.trimEnd(u8, text, "\n"); + if (!first) try out.appendSlice(allocator, sep); + try out.appendSlice(allocator, trimmed); + first = false; } } @@ -208,6 +214,11 @@ fn writeBlock(s: *std.json.Stringify, block: conversation.ContentBlock) !void { try s.write(tr.content.items); try s.endObject(); }, + // System blocks never reach here: `serializeRequest` filters out + // `.system` messages before emitting the `messages` array, and the + // system text is hoisted into the top-level `system` string by + // `collectSystemPrompt`. Present only to keep the switch exhaustive. + .System => {}, } } @@ -526,7 +537,7 @@ test "serializeRequest - system extracted into top-level field" { try testing.expectEqualStrings("Hello!", content[0].object.get("text").?.string); } -test "serializeRequest - multiple system messages concatenated with newlines" { +test "serializeRequest - multiple system messages joined with horizontal rule" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); @@ -545,7 +556,57 @@ test "serializeRequest - multiple system messages concatenated with newlines" { var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); defer parsed.deinit(); try testing.expectEqualStrings( - "Be terse.\nBe accurate.", + "Be terse.\n\n---\n\nBe accurate.", + parsed.value.object.get("system").?.string, + ); +} + +test "serializeRequest - replace-mode system block wipes prior system text" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + + try conv.addSystemMessage("original seed"); + try conv.addSystemMessage("original append"); + try conv.replaceSystemMessage("fresh seed"); + try conv.addSystemMessage("fresh append"); + try conv.addUserMessage("Hi"); + + const cfg = testConfig("claude-x"); + var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); + defer empty_tools.deinit(); + const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools); + defer allocator.free(body); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); + defer parsed.deinit(); + try testing.expectEqualStrings( + "fresh seed\n\n---\n\nfresh append", + parsed.value.object.get("system").?.string, + ); +} + +test "serializeRequest - trailing newlines stripped before the rule join" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + + try conv.addSystemMessage("line one\n\n"); + try conv.addSystemMessage("line two\n"); + try conv.addUserMessage("Hi"); + + const cfg = testConfig("claude-x"); + var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); + defer empty_tools.deinit(); + const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools); + defer allocator.free(body); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); + defer parsed.deinit(); + try testing.expectEqualStrings( + "line one\n\n---\n\nline two", parsed.value.object.get("system").?.string, ); } diff --git a/libpanto/src/conversation.zig b/libpanto/src/conversation.zig index 6776412..f145d7f 100644 --- a/libpanto/src/conversation.zig +++ b/libpanto/src/conversation.zig @@ -51,11 +51,29 @@ pub const ToolResultBlock = struct { } }; +/// How a `.system` content block combines with the system text collected +/// before it. `append` adds to the running effective prompt; `replace` +/// discards everything collected so far and starts fresh from this block. +pub const SystemMode = enum { append, replace }; + +/// A system-prompt content block. System prompts remain `.system`-role +/// messages; this block records the mode that governs how its text folds +/// into the effective system prompt (see `effectiveSystemBlocks`). +pub const SystemBlock = struct { + text: TextualBlock = .empty, + mode: SystemMode = .append, + + pub fn deinit(self: *SystemBlock, alloc: Allocator) void { + self.text.deinit(alloc); + } +}; + pub const ContentBlock = union(enum) { Text: TextualBlock, Thinking: ThinkingBlock, ToolUse: ToolUseBlock, ToolResult: ToolResultBlock, + System: SystemBlock, pub fn deinit(self: *ContentBlock, alloc: Allocator) void { switch (self.*) { @@ -63,6 +81,7 @@ pub const ContentBlock = union(enum) { .Thinking => |*b| b.deinit(alloc), .ToolUse => |*b| b.deinit(alloc), .ToolResult => |*b| b.deinit(alloc), + .System => |*b| b.deinit(alloc), } } }; @@ -95,10 +114,30 @@ pub const Conversation = struct { }; } + /// Append a system message in `append` mode. Adds to the effective + /// system prompt. (Back-compatible: same external behavior as before + /// the `.System` block existed.) pub fn addSystemMessage(self: *Conversation, text: []const u8) !void { + return self.appendSystemBlock(text, .append); + } + + /// Append a system message in `replace` mode. When the effective + /// prompt is rebuilt (see `effectiveSystemBlocks`), this discards all + /// prior system text and starts fresh. + pub fn replaceSystemMessage(self: *Conversation, text: []const u8) !void { + return self.appendSystemBlock(text, .replace); + } + + /// Append a `.system`-role message whose single content block is a + /// `.System` block carrying `mode`. + fn appendSystemBlock(self: *Conversation, text: []const u8, mode: SystemMode) !void { const tb = try textualBlockFromSlice(self.allocator, text); var content: std.ArrayList(ContentBlock) = .empty; - try content.append(self.allocator, .{ .Text = tb }); + errdefer { + for (content.items) |*b| b.deinit(self.allocator); + content.deinit(self.allocator); + } + try content.append(self.allocator, .{ .System = .{ .text = tb, .mode = mode } }); try self.messages.append(self.allocator, .{ .role = .system, .content = content, @@ -137,6 +176,50 @@ pub const Conversation = struct { } }; +/// Derive the effective ordered list of system-text blocks from a slice of +/// messages. This is the single shared rule that governs both provider +/// serialization and session rebuild. +/// +/// Walk the messages in order; for each `.system` message's `.System` +/// block: +/// - `append`: add the block's text to the running list. +/// - `replace`: clear the running list, then add this block's text. +/// +/// The returned slices are **borrowed** from `messages` — valid only as +/// long as the underlying conversation is unmodified. The caller owns the +/// returned `ArrayList` itself and must `deinit` it (this frees the slice +/// storage, not the borrowed text). +/// +/// Running this walk over a *prefix* of the messages reconstructs the +/// effective prompt as of that point — the `/tree` faithfulness property. +pub fn effectiveSystemBlocks( + alloc: Allocator, + messages: []const Message, +) !std.ArrayList([]const u8) { + var out: std.ArrayList([]const u8) = .empty; + errdefer out.deinit(alloc); + for (messages) |msg| { + if (msg.role != .system) continue; + for (msg.content.items) |block| { + switch (block) { + .System => |sb| { + switch (sb.mode) { + .append => {}, + .replace => out.clearRetainingCapacity(), + } + try out.append(alloc, sb.text.items); + }, + // Be tolerant of plain `.Text` blocks on a system message + // (e.g. hand-built test conversations): treat them as + // append-mode text. + .Text => |tb| try out.append(alloc, tb.items), + else => {}, + } + } + } + return out; +} + test "Conversation - add messages and verify content" { const allocator = std.testing.allocator; @@ -154,7 +237,11 @@ test "Conversation - add messages and verify content" { try std.testing.expectEqual(MessageRole.system, conv.messages.items[0].role); try std.testing.expectEqualStrings( "You are a helpful assistant.", - conv.messages.items[0].content.items[0].Text.items, + conv.messages.items[0].content.items[0].System.text.items, + ); + try std.testing.expectEqual( + SystemMode.append, + conv.messages.items[0].content.items[0].System.mode, ); try std.testing.expectEqual(MessageRole.user, conv.messages.items[1].role); @@ -202,3 +289,74 @@ test "ContentBlock - Thinking variant" { try std.testing.expectEqualStrings("hmm...", conv.messages.items[0].content.items[0].Thinking.text.items); try std.testing.expectEqualStrings("answer", conv.messages.items[0].content.items[1].Text.items); } + +test "System block - addSystemMessage records append mode, replaceSystemMessage records replace mode" { + const allocator = std.testing.allocator; + + var conv = Conversation.init(allocator); + defer conv.deinit(); + + try conv.addSystemMessage("base"); + try conv.replaceSystemMessage("fresh"); + + try std.testing.expectEqual(SystemMode.append, conv.messages.items[0].content.items[0].System.mode); + try std.testing.expectEqualStrings("base", conv.messages.items[0].content.items[0].System.text.items); + try std.testing.expectEqual(SystemMode.replace, conv.messages.items[1].content.items[0].System.mode); + try std.testing.expectEqualStrings("fresh", conv.messages.items[1].content.items[0].System.text.items); +} + +test "effectiveSystemBlocks - append accumulates in order" { + const allocator = std.testing.allocator; + + var conv = Conversation.init(allocator); + defer conv.deinit(); + + try conv.addSystemMessage("a"); + try conv.addSystemMessage("b"); + try conv.addUserMessage("hi"); + try conv.addSystemMessage("c"); + + var blocks = try effectiveSystemBlocks(allocator, conv.messages.items); + defer blocks.deinit(allocator); + + try std.testing.expectEqual(@as(usize, 3), blocks.items.len); + try std.testing.expectEqualStrings("a", blocks.items[0]); + try std.testing.expectEqualStrings("b", blocks.items[1]); + try std.testing.expectEqualStrings("c", blocks.items[2]); +} + +test "effectiveSystemBlocks - replace wipes everything collected so far" { + const allocator = std.testing.allocator; + + var conv = Conversation.init(allocator); + defer conv.deinit(); + + try conv.addSystemMessage("a"); + try conv.addSystemMessage("b"); + try conv.replaceSystemMessage("fresh"); + try conv.addSystemMessage("after"); + + var blocks = try effectiveSystemBlocks(allocator, conv.messages.items); + defer blocks.deinit(allocator); + + try std.testing.expectEqual(@as(usize, 2), blocks.items.len); + try std.testing.expectEqualStrings("fresh", blocks.items[0]); + try std.testing.expectEqualStrings("after", blocks.items[1]); +} + +test "effectiveSystemBlocks - prefix reconstructs prompt as of that point" { + const allocator = std.testing.allocator; + + var conv = Conversation.init(allocator); + defer conv.deinit(); + + try conv.addSystemMessage("a"); + try conv.replaceSystemMessage("fresh"); + try conv.addSystemMessage("after"); + + // Truncate at position 1 (only the first `addSystemMessage`). + var blocks = try effectiveSystemBlocks(allocator, conv.messages.items[0..1]); + defer blocks.deinit(allocator); + try std.testing.expectEqual(@as(usize, 1), blocks.items.len); + try std.testing.expectEqualStrings("a", blocks.items[0]); +} diff --git a/libpanto/src/openai_chat_json.zig b/libpanto/src/openai_chat_json.zig index 6991559..3b81c41 100644 --- a/libpanto/src/openai_chat_json.zig +++ b/libpanto/src/openai_chat_json.zig @@ -140,7 +140,24 @@ pub fn serializeRequest( try s.objectField("messages"); try s.beginArray(); + // Hoist the effective system prompt to the front as separate, + // individually-positioned `system` messages (one per surviving block, + // in derivation order). Keeping them distinct preserves block-level + // addressability for `/tree`-style truncation — we deliberately do NOT + // concatenate them the way Anthropic's single-string format forces. + var sys_blocks = try conversation.effectiveSystemBlocks(allocator, conv.messages.items); + defer sys_blocks.deinit(allocator); + for (sys_blocks.items) |text| { + try s.beginObject(); + try s.objectField("role"); + try s.write("system"); + try s.objectField("content"); + try s.write(text); + try s.endObject(); + } + // Then every non-system message, in its original order. for (conv.messages.items) |msg| { + if (msg.role == .system) continue; try writeMessage(&s, msg, allocator); } try s.endArray(); @@ -497,6 +514,68 @@ test "serializeRequest - system + user" { try testing.expectEqualStrings("Hello!", msgs[1].object.get("content").?.string); } +test "serializeRequest - multiple system blocks hoisted as separate leading messages" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + + try conv.addSystemMessage("seed"); + try conv.addUserMessage("Hello!"); + try conv.addSystemMessage("mid-conversation append"); + try conv.addUserMessage("again"); + + const cfg = testConfig("gpt-4o"); + var tools = emptyTools(); + defer tools.deinit(); + const body = try serializeRequest(allocator, &cfg, &conv, &tools); + defer allocator.free(body); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); + defer parsed.deinit(); + + const msgs = parsed.value.object.get("messages").?.array.items; + // Two system messages first (in derivation order), then the two users. + try testing.expectEqual(@as(usize, 4), msgs.len); + try testing.expectEqualStrings("system", msgs[0].object.get("role").?.string); + try testing.expectEqualStrings("seed", msgs[0].object.get("content").?.string); + try testing.expectEqualStrings("system", msgs[1].object.get("role").?.string); + try testing.expectEqualStrings("mid-conversation append", msgs[1].object.get("content").?.string); + try testing.expectEqualStrings("user", msgs[2].object.get("role").?.string); + try testing.expectEqualStrings("Hello!", msgs[2].object.get("content").?.string); + try testing.expectEqualStrings("user", msgs[3].object.get("role").?.string); + try testing.expectEqualStrings("again", msgs[3].object.get("content").?.string); +} + +test "serializeRequest - replace-mode system block wipes prior system messages" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + + try conv.addSystemMessage("original"); + try conv.replaceSystemMessage("fresh seed"); + try conv.addSystemMessage("fresh append"); + try conv.addUserMessage("Hi"); + + const cfg = testConfig("gpt-4o"); + var tools = emptyTools(); + defer tools.deinit(); + const body = try serializeRequest(allocator, &cfg, &conv, &tools); + defer allocator.free(body); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); + defer parsed.deinit(); + + const msgs = parsed.value.object.get("messages").?.array.items; + try testing.expectEqual(@as(usize, 3), msgs.len); + try testing.expectEqualStrings("system", msgs[0].object.get("role").?.string); + try testing.expectEqualStrings("fresh seed", msgs[0].object.get("content").?.string); + try testing.expectEqualStrings("system", msgs[1].object.get("role").?.string); + try testing.expectEqualStrings("fresh append", msgs[1].object.get("content").?.string); + try testing.expectEqualStrings("user", msgs[2].object.get("role").?.string); +} + test "serializeRequest - assistant Thinking blocks are stripped from outbound history" { const allocator = testing.allocator; diff --git a/libpanto/src/session.zig b/libpanto/src/session.zig index e77d121..c1fd1c4 100644 --- a/libpanto/src/session.zig +++ b/libpanto/src/session.zig @@ -100,9 +100,18 @@ pub const MessageEntry = struct { pub const DiskMessageRole = enum { system, user, assistant }; +/// Mode for a system-role message. Mirrors `conversation.SystemMode`. +/// `append` adds to the effective prompt; `replace` discards all prior +/// system text. Only meaningful on system messages; absent on disk means +/// `append` (back-compatible with pre-mode logs). +pub const DiskSystemMode = enum { append, replace }; + pub const DiskMessage = struct { role: DiskMessageRole, content: []DiskContentBlock, // owned + /// System-message mode. Recorded only for system-role messages; an + /// absent `mode` on disk parses back as `.append`. + mode: DiskSystemMode = .append, // Assistant-only metadata. Null for system/user messages. provider: ?[]const u8 = null, // owned model: ?[]const u8 = null, // owned @@ -296,6 +305,12 @@ fn writeDiskMessage(s: *std.json.Stringify, msg: DiskMessage) !void { try s.beginObject(); try s.objectField("role"); try s.write(@tagName(msg.role)); + // `mode` is meaningful only for system messages. Emit it there so the + // append/replace semantics round-trip; omit it everywhere else. + if (msg.role == .system) { + try s.objectField("mode"); + try s.write(@tagName(msg.mode)); + } try s.objectField("content"); try s.beginArray(); for (msg.content) |block| { @@ -479,6 +494,14 @@ fn parseDiskMessage(allocator: Allocator, obj: std.json.ObjectMap) ParseError!Di if (role_v != .string) return error.MissingField; const role = std.meta.stringToEnum(DiskMessageRole, role_v.string) orelse return error.UnknownRole; + // `mode` is optional; absent defaults to `.append`. Unknown values are + // tolerated as `.append` rather than rejecting an otherwise-valid log. + const mode: DiskSystemMode = blk: { + const mv = obj.get("mode") orelse break :blk .append; + if (mv != .string) break :blk .append; + break :blk std.meta.stringToEnum(DiskSystemMode, mv.string) orelse .append; + }; + const content_v = obj.get("content") orelse return error.MissingField; if (content_v != .array) return error.MissingField; var content_list = try std.ArrayList(DiskContentBlock).initCapacity(allocator, content_v.array.items.len); @@ -520,6 +543,7 @@ fn parseDiskMessage(allocator: Allocator, obj: std.json.ObjectMap) ParseError!Di return .{ .role = role, .content = content, + .mode = mode, .provider = provider, .model = model, .stop_reason = stop_reason, @@ -612,6 +636,13 @@ pub fn contentBlockToDisk( const content = try allocator.dupe(u8, tr.content.items); return .{ .tool_result = .{ .tool_use_id = tuid, .content = content } }; }, + // A `.System` block becomes a disk text block; its mode rides on + // the enclosing `DiskMessage.mode` (set by the session manager), + // not on the block itself. + .System => |sb| { + const text = try allocator.dupe(u8, sb.text.items); + return .{ .text = .{ .text = text } }; + }, } } @@ -808,6 +839,47 @@ test "serialize/parse tool result message entry" { try testing.expectEqualStrings("anthropic", got.provider.?); } +test "system message mode round-trips; absent mode defaults to append" { + const a = testing.allocator; + + // replace-mode system entry round-trips. + { + var content = try a.alloc(DiskContentBlock, 1); + content[0] = .{ .text = .{ .text = try dupe(a, "fresh seed") } }; + const entry: SessionEntry = .{ .message = .{ + .base = .{ + .id = try dupe(a, "aabbccdd"), + .parent_id = null, + .timestamp = try dupe(a, "2026-04-25T17:40:00Z"), + }, + .message = .{ + .role = .system, + .content = content, + .mode = .replace, + }, + } }; + defer entry.deinit(a); + + const line = try serializeEntry(a, entry); + defer a.free(line); + try testing.expect(std.mem.indexOf(u8, line, "\"mode\":\"replace\"") != null); + + var fe = try parseLine(a, line); + defer fe.deinit(a); + try testing.expectEqual(DiskSystemMode.replace, fe.entry.message.message.mode); + } + + // A legacy system entry with no `mode` parses back as append. + { + const line = + \\{"type":"message","id":"abcdefab","parentId":null,"timestamp":"2026-04-25T17:40:00Z","message":{"role":"system","content":[{"type":"text","text":"hi"}]}} + ; + var fe = try parseLine(a, line); + defer fe.deinit(a); + try testing.expectEqual(DiskSystemMode.append, fe.entry.message.message.mode); + } +} + test "parse: null parentId is handled" { const a = testing.allocator; const line = diff --git a/libpanto/src/session_manager.zig b/libpanto/src/session_manager.zig index bf9585d..8b503f2 100644 --- a/libpanto/src/session_manager.zig +++ b/libpanto/src/session_manager.zig @@ -37,6 +37,7 @@ pub const SessionEntry = session_mod.SessionEntry; pub const MessageEntry = session_mod.MessageEntry; pub const DiskMessage = session_mod.DiskMessage; pub const DiskMessageRole = session_mod.DiskMessageRole; +pub const DiskSystemMode = session_mod.DiskSystemMode; pub const DiskContentBlock = session_mod.DiskContentBlock; pub const Usage = session_mod.Usage; pub const CURRENT_VERSION = session_mod.CURRENT_VERSION; @@ -668,8 +669,19 @@ fn appendMessageToConv( content.deinit(allocator); } try content.ensureTotalCapacity(allocator, disk_msg.content.len); + const sys_mode: conversation_mod.SystemMode = switch (disk_msg.mode) { + .append => .append, + .replace => .replace, + }; for (disk_msg.content) |db| { - const block = try session_mod.diskContentBlockToInternal(allocator, db); + var block = try session_mod.diskContentBlockToInternal(allocator, db); + // System-role text blocks become `.System` blocks carrying the + // message's recorded mode, so the append/replace derivation works + // on the rebuilt conversation exactly as it did when written. + if (disk_msg.role == .system and block == .Text) { + const tb = block.Text; + block = .{ .System = .{ .text = tb, .mode = sys_mode } }; + } content.appendAssumeCapacity(block); } const role: conversation_mod.MessageRole = switch (disk_msg.role) { @@ -1248,7 +1260,7 @@ test "SessionManager: rebuildConversation reconstructs system/user/assistant tur defer conv.deinit(); try testing.expectEqual(@as(usize, 3), conv.messages.items.len); try testing.expectEqual(conversation_mod.MessageRole.system, conv.messages.items[0].role); - try testing.expectEqualStrings("you are helpful", conv.messages.items[0].content.items[0].Text.items); + try testing.expectEqualStrings("you are helpful", conv.messages.items[0].content.items[0].System.text.items); try testing.expectEqual(conversation_mod.MessageRole.user, conv.messages.items[1].role); try testing.expectEqualStrings("hello", conv.messages.items[1].content.items[0].Text.items); try testing.expectEqual(conversation_mod.MessageRole.assistant, conv.messages.items[2].role); diff --git a/src/main.zig b/src/main.zig index aaf86b5..20686eb 100644 --- a/src/main.zig +++ b/src/main.zig @@ -11,6 +11,7 @@ const session_paths = @import("session_paths.zig"); const models_toml = @import("models_toml.zig"); const config_file = @import("config_file.zig"); const glob = @import("glob.zig"); +const system_prompt = @import("system_prompt.zig"); // Shorthand alias for the Lua C API. The bridge module owns the actual // `@cImport`; we re-use it here so the smoke check uses identical types. @@ -37,6 +38,7 @@ test { _ = models_toml; _ = config_file; _ = glob; + _ = system_prompt; } const Receiver = panto.provider.Receiver; @@ -363,20 +365,18 @@ pub fn main(init: std.process.Init) !void { var conv = panto.conversation.Conversation.init(alloc); defer conv.deinit(); - if (session_mgr.getEntries().len > 0) { + const is_resume = session_mgr.getEntries().len > 0; + if (is_resume) { // Resumed an existing session — rebuild the conversation from the - // log. The system prompt is part of the log. + // log. The system prompt is part of the log. (Reconciliation + // against the on-disk SYSTEM.md happens after the agent tree is + // staged, below.) conv.deinit(); conv = try session_mgr.rebuildConversation(); try stdout.print( "resumed session {s} ({d} entries)\n", .{ session_mgr.getSessionId()[0..@min(8, session_mgr.getSessionId().len)], session_mgr.getEntries().len }, ); - } else { - // Fresh session — install the default system prompt and record it. - const system_text = "You are a helpful assistant."; - try conv.addSystemMessage(system_text); - try appendSystemToSession(alloc, &session_mgr, system_text); } // The tool registry is owned here and referenced by the active config @@ -405,6 +405,36 @@ pub fn main(init: std.process.Init) !void { ); defer luarocks_rt.deinit(); + // Source the system prompt now that the base agent tree has been + // staged to `$PANTO_HOME/agent` (the bootstrap above writes the + // bundled `SYSTEM.md` there). The prompt is sourced by convention + // from SYSTEM.md / APPEND_SYSTEM.md across the base/user/project + // layers; the base layer is `luarocks_rt.layout.agent_dir`. + var sp_arena = std.heap.ArenaAllocator.init(alloc); + defer sp_arena.deinit(); + if (is_resume) { + // SYSTEM.md / APPEND_SYSTEM.md may have changed since this log was + // created; reconcile without rewriting history. + try system_prompt.reconcileResume( + sp_arena.allocator(), + io, + init.environ_map, + luarocks_rt.layout.agent_dir, + &conv, + &session_mgr, + ); + } else { + // Fresh session — source and install the system prompt. + try system_prompt.seedFresh( + sp_arena.allocator(), + io, + init.environ_map, + luarocks_rt.layout.agent_dir, + &conv, + &session_mgr, + ); + } + // luv is installed (or already present) at this point; wire the // libuv-driven coroutine scheduler before any extensions get a // chance to register tools that might want to yield. @@ -637,20 +667,6 @@ fn openSession( // Session append helpers — bridge in-memory ContentBlocks to on-disk entries. // ----------------------------------------------------------------------------- -fn appendSystemToSession( - alloc: std.mem.Allocator, - mgr: *panto.session_manager.SessionManager, - text: []const u8, -) !void { - const blocks = try alloc.alloc(panto.session_manager.DiskContentBlock, 1); - blocks[0] = .{ .text = .{ .text = try alloc.dupe(u8, text) } }; - _ = try mgr.appendMessage( - .{ .role = .system, .content = blocks }, - null, - null, - ); -} - fn appendUserPromptToSession( alloc: std.mem.Allocator, mgr: *panto.session_manager.SessionManager, diff --git a/src/system_prompt.zig b/src/system_prompt.zig new file mode 100644 index 0000000..b796394 --- /dev/null +++ b/src/system_prompt.zig @@ -0,0 +1,541 @@ +//! System-prompt sourcing and resume reconciliation for the `panto` CLI. +//! +//! The system prompt is **conversation state**, not config state (see +//! `docs/system-prompt.md`). It is sourced — by convention, with no TOML +//! key — from two files discovered across the same three config layers the +//! CLI already uses, in precedence order base → user → project (project +//! highest): +//! +//! base = $PANTO_HOME/agent/ (= $XDG_DATA_HOME/panto/agent/) +//! user = ${XDG_CONFIG_HOME:-$HOME/.config}/panto/ +//! project = ./.panto/ +//! +//! In each layer we look for: +//! - `SYSTEM.md` — the base/seed prompt (highest layer present wins). +//! - `APPEND_SYSTEM.md` — an additional appended block (every layer kept). +//! +//! Resolution: +//! 1. Seed = the highest-layer `SYSTEM.md`, else a built-in default. +//! 2. Appends = each layer's `APPEND_SYSTEM.md`, collected base → user → +//! project, then emitted reversed: project → user → base. The seed +//! leads; the most-specific (project) append follows it. + +const std = @import("std"); +const panto = @import("panto"); + +const Allocator = std.mem.Allocator; +const Io = std.Io; + +/// Last-resort fallback seed when no `SYSTEM.md` exists at any layer. +/// +/// In normal operation this is never used: the base layer ships a bundled +/// `agent/SYSTEM.md` (embedded in the binary and staged to +/// `$PANTO_HOME/agent/SYSTEM.md` at bootstrap), so a seed is always found. +/// This constant only matters if that staged file is missing or +/// unreadable. +pub const default_seed = "You are a helpful assistant."; + +const seed_filename = "SYSTEM.md"; +const append_filename = "APPEND_SYSTEM.md"; + +/// A resolved set of system blocks for a fresh session: the seed followed +/// by zero or more appends, already in emission order. All slices +/// are owned by `arena`. +pub const Resolved = struct { + /// Seed text (always present — a built-in default if no file existed). + seed: []const u8, + /// Append blocks in emission order (project → user → base). + appends: []const []const u8, + + /// The full ordered block list: `[seed, appends...]`. Owned by `arena`. + pub fn blocks(self: Resolved, arena: Allocator) ![]const []const u8 { + var list = try arena.alloc([]const u8, 1 + self.appends.len); + list[0] = self.seed; + @memcpy(list[1..], self.appends); + return list; + } +}; + +/// Resolve the system-prompt blocks from the three config layers. +/// +/// `base_dir` is the base layer directory (`$PANTO_HOME/agent`). The user +/// layer is derived from `XDG_CONFIG_HOME`/`HOME`; the project layer is +/// `cwd()/.panto`. Allocations are made in `arena` (caller owns it). +pub fn resolve( + arena: Allocator, + io: Io, + environ_map: *const std.process.Environ.Map, + base_dir: []const u8, +) !Resolved { + const user_dir = try userLayerDir(arena, environ_map); + const project_dir = try projectLayerDir(arena, io); + return resolveLayers(arena, io, base_dir, user_dir, project_dir); +} + +/// Core resolution against explicit layer directories (base → user → +/// project, project highest). Any layer dir may be null (absent). Exposed +/// for testing; `resolve` derives the user/project dirs from the +/// environment and cwd. +pub fn resolveLayers( + arena: Allocator, + io: Io, + base_dir: ?[]const u8, + user_dir: ?[]const u8, + project_dir: ?[]const u8, +) !Resolved { + // Seed: highest layer present wins (project > user > base). + var seed: []const u8 = default_seed; + for ([_]?[]const u8{ base_dir, user_dir, project_dir }) |maybe_dir| { + const dir = maybe_dir orelse continue; + if (try readLayerFile(arena, io, dir, seed_filename)) |text| { + seed = text; + } + } + + // Appends: collect base → user → project, then reverse for emission. + var collected: std.ArrayList([]const u8) = .empty; + for ([_]?[]const u8{ base_dir, user_dir, project_dir }) |maybe_dir| { + const dir = maybe_dir orelse continue; + if (try readLayerFile(arena, io, dir, append_filename)) |text| { + try collected.append(arena, text); + } + } + std.mem.reverse([]const u8, collected.items); + + return .{ .seed = seed, .appends = try collected.toOwnedSlice(arena) }; +} + +/// Seed a fresh conversation with the resolved blocks and record matching +/// `append`-mode entries in the session log. +pub fn seedFresh( + arena: Allocator, + io: Io, + environ_map: *const std.process.Environ.Map, + base_dir: []const u8, + conv: *panto.conversation.Conversation, + mgr: *panto.session_manager.SessionManager, +) !void { + const user_dir = try userLayerDir(arena, environ_map); + const project_dir = try projectLayerDir(arena, io); + return seedFreshLayers(arena, io, base_dir, user_dir, project_dir, conv, mgr); +} + +/// Layer-explicit variant of `seedFresh` (see `resolveLayers`). +pub fn seedFreshLayers( + arena: Allocator, + io: Io, + base_dir: ?[]const u8, + user_dir: ?[]const u8, + project_dir: ?[]const u8, + conv: *panto.conversation.Conversation, + mgr: *panto.session_manager.SessionManager, +) !void { + const resolved = try resolveLayers(arena, io, base_dir, user_dir, project_dir); + const blocks = try resolved.blocks(arena); + for (blocks) |text| { + try conv.addSystemMessage(text); + try appendSystemEntry(mgr.allocator, mgr, text, .append); + } +} + +/// Reconcile a resumed conversation against the current on-disk config. +/// +/// Compares the freshly-resolved config blocks, by position and exact +/// text, against the conversation's **current effective config window**: +/// the last `replace`-mode system block plus every system block after it. +/// If they match, this is a no-op. Otherwise it appends a fresh +/// `replace + N×append` sequence to both the conversation and the log, +/// re-seeding the prompt wholesale without rewriting history. +pub fn reconcileResume( + arena: Allocator, + io: Io, + environ_map: *const std.process.Environ.Map, + base_dir: []const u8, + conv: *panto.conversation.Conversation, + mgr: *panto.session_manager.SessionManager, +) !void { + const user_dir = try userLayerDir(arena, environ_map); + const project_dir = try projectLayerDir(arena, io); + return reconcileResumeLayers(arena, io, base_dir, user_dir, project_dir, conv, mgr); +} + +/// Layer-explicit variant of `reconcileResume` (see `resolveLayers`). +pub fn reconcileResumeLayers( + arena: Allocator, + io: Io, + base_dir: ?[]const u8, + user_dir: ?[]const u8, + project_dir: ?[]const u8, + conv: *panto.conversation.Conversation, + mgr: *panto.session_manager.SessionManager, +) !void { + const resolved = try resolveLayers(arena, io, base_dir, user_dir, project_dir); + const config_blocks = try resolved.blocks(arena); + + const window = try effectiveConfigWindow(arena, conv.messages.items); + + if (blocksEqual(config_blocks, window)) return; + + // Re-seed: one `replace` for the seed, then one `append` per append. + try conv.replaceSystemMessage(config_blocks[0]); + try appendSystemEntry(mgr.allocator, mgr, config_blocks[0], .replace); + for (config_blocks[1..]) |text| { + try conv.addSystemMessage(text); + try appendSystemEntry(mgr.allocator, mgr, text, .append); + } +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +/// The current effective config window: the text of the last `replace`-mode +/// system block plus every system block after it, in order. If there is no +/// `replace` block, the window is every leading system block (the original +/// seed sequence). Returned slices borrow from `messages`. +fn effectiveConfigWindow( + arena: Allocator, + messages: []const panto.conversation.Message, +) ![]const []const u8 { + // Find the index of the last message carrying a `replace`-mode block. + var anchor: ?usize = null; + for (messages, 0..) |msg, i| { + if (msg.role != .system) continue; + for (msg.content.items) |block| { + switch (block) { + .System => |sb| if (sb.mode == .replace) { + anchor = i; + }, + else => {}, + } + } + } + + var out: std.ArrayList([]const u8) = .empty; + const start = anchor orelse 0; + for (messages[start..]) |msg| { + if (msg.role != .system) continue; + for (msg.content.items) |block| { + switch (block) { + .System => |sb| try out.append(arena, sb.text.items), + .Text => |tb| try out.append(arena, tb.items), + else => {}, + } + } + } + return out.toOwnedSlice(arena); +} + +fn blocksEqual(a: []const []const u8, b: []const []const u8) bool { + if (a.len != b.len) return false; + for (a, b) |x, y| { + if (!std.mem.eql(u8, x, y)) return false; + } + return true; +} + +/// Append a system-role entry to the session log carrying `mode`. +fn appendSystemEntry( + alloc: Allocator, + mgr: *panto.session_manager.SessionManager, + text: []const u8, + mode: panto.session_manager.DiskSystemMode, +) !void { + const blocks = try alloc.alloc(panto.session_manager.DiskContentBlock, 1); + blocks[0] = .{ .text = .{ .text = try alloc.dupe(u8, text) } }; + _ = try mgr.appendMessage( + .{ .role = .system, .mode = mode, .content = blocks }, + null, + null, + ); +} + +/// Read `dir/name`, returning its contents (owned by `arena`) or null if the +/// file does not exist. Other I/O errors propagate. +fn readLayerFile( + arena: Allocator, + io: Io, + dir: []const u8, + name: []const u8, +) !?[]u8 { + const path = try std.fs.path.join(arena, &.{ dir, name }); + const file = Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only }) catch |err| switch (err) { + error.FileNotFound => return null, + else => return err, + }; + defer file.close(io); + const len = file.length(io) catch return error.SystemPromptReadFailed; + const bytes = try arena.alloc(u8, @intCast(len)); + errdefer arena.free(bytes); + _ = file.readPositionalAll(io, bytes, 0) catch return error.SystemPromptReadFailed; + return bytes; +} + +fn userLayerDir(arena: Allocator, environ_map: *const std.process.Environ.Map) !?[]u8 { + if (environ_map.get("XDG_CONFIG_HOME")) |xdg| { + return try std.fs.path.join(arena, &.{ xdg, "panto" }); + } + if (environ_map.get("HOME")) |home| { + return try std.fs.path.join(arena, &.{ home, ".config", "panto" }); + } + return null; +} + +fn projectLayerDir(arena: Allocator, io: Io) ![]u8 { + const cwd = try std.process.currentPathAlloc(io, arena); + return try std.fs.path.join(arena, &.{ cwd, ".panto" }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +test "blocksEqual - length and content" { + try testing.expect(blocksEqual(&.{ "a", "b" }, &.{ "a", "b" })); + try testing.expect(!blocksEqual(&.{ "a", "b" }, &.{ "a", "c" })); + try testing.expect(!blocksEqual(&.{"a"}, &.{ "a", "b" })); + try testing.expect(blocksEqual(&.{}, &.{})); +} + +test "effectiveConfigWindow - no replace anchors to leading system blocks" { + const alloc = testing.allocator; + var conv = panto.conversation.Conversation.init(alloc); + defer conv.deinit(); + + try conv.addSystemMessage("seed"); + try conv.addSystemMessage("append-1"); + try conv.addUserMessage("hi"); + + var arena_state = std.heap.ArenaAllocator.init(alloc); + defer arena_state.deinit(); + const window = try effectiveConfigWindow(arena_state.allocator(), conv.messages.items); + + try testing.expectEqual(@as(usize, 2), window.len); + try testing.expectEqualStrings("seed", window[0]); + try testing.expectEqualStrings("append-1", window[1]); +} + +test "effectiveConfigWindow - anchors to last replace block" { + const alloc = testing.allocator; + var conv = panto.conversation.Conversation.init(alloc); + defer conv.deinit(); + + try conv.addSystemMessage("old seed"); + try conv.addUserMessage("hi"); + try conv.replaceSystemMessage("new seed"); + try conv.addSystemMessage("new append"); + try conv.addUserMessage("again"); + + var arena_state = std.heap.ArenaAllocator.init(alloc); + defer arena_state.deinit(); + const window = try effectiveConfigWindow(arena_state.allocator(), conv.messages.items); + + try testing.expectEqual(@as(usize, 2), window.len); + try testing.expectEqualStrings("new seed", window[0]); + try testing.expectEqualStrings("new append", window[1]); +} + +fn writeTmpFile(dir: Io.Dir, sub_path: []const u8, content: []const u8) !void { + try dir.writeFile(testing.io, .{ .sub_path = sub_path, .data = content }); +} + +test "resolveLayers - highest SYSTEM.md wins, appends reversed project-first" { + var tmp = testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + + // base: SYSTEM.md + APPEND_SYSTEM.md; user: APPEND only; project: + // SYSTEM.md + APPEND_SYSTEM.md (matches the design's worked example). + try tmp.dir.createDirPath(testing.io, "base"); + try tmp.dir.createDirPath(testing.io, "user"); + try tmp.dir.createDirPath(testing.io, "project"); + try writeTmpFile(tmp.dir, "base/SYSTEM.md", "base seed"); + try writeTmpFile(tmp.dir, "base/APPEND_SYSTEM.md", "base append"); + try writeTmpFile(tmp.dir, "user/APPEND_SYSTEM.md", "user append"); + try writeTmpFile(tmp.dir, "project/SYSTEM.md", "project seed"); + try writeTmpFile(tmp.dir, "project/APPEND_SYSTEM.md", "project append"); + + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const root_len = try tmp.dir.realPathFile(testing.io, ".", &buf); + const root = buf[0..root_len]; + const base_dir = try std.fs.path.join(arena, &.{ root, "base" }); + const user_dir = try std.fs.path.join(arena, &.{ root, "user" }); + const project_dir = try std.fs.path.join(arena, &.{ root, "project" }); + + const resolved = try resolveLayers(arena, testing.io, base_dir, user_dir, project_dir); + try testing.expectEqualStrings("project seed", resolved.seed); + + const blocks = try resolved.blocks(arena); + try testing.expectEqual(@as(usize, 4), blocks.len); + try testing.expectEqualStrings("project seed", blocks[0]); + try testing.expectEqualStrings("project append", blocks[1]); + try testing.expectEqualStrings("user append", blocks[2]); + try testing.expectEqualStrings("base append", blocks[3]); +} + +test "resolveLayers - no SYSTEM.md anywhere falls back to built-in default" { + var tmp = testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const root_len = try tmp.dir.realPathFile(testing.io, ".", &buf); + const root = buf[0..root_len]; + + const resolved = try resolveLayers(arena, testing.io, root, null, null); + try testing.expectEqualStrings(default_seed, resolved.seed); + try testing.expectEqual(@as(usize, 0), resolved.appends.len); +} + +const TmpLayers = struct { + tmp: std.testing.TmpDir, + root: []u8, + + fn init(arena: Allocator) !TmpLayers { + var tmp = std.testing.tmpDir(.{ .iterate = true }); + errdefer tmp.cleanup(); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const n = try tmp.dir.realPathFile(testing.io, ".", &buf); + const root = try arena.dupe(u8, buf[0..n]); + try tmp.dir.createDirPath(testing.io, "project"); + return .{ .tmp = tmp, .root = root }; + } + + fn projectDir(self: TmpLayers, arena: Allocator) ![]u8 { + return std.fs.path.join(arena, &.{ self.root, "project" }); + } + + fn writeProject(self: *TmpLayers, name: []const u8, content: []const u8) !void { + const sub = try std.fmt.allocPrint(testing.allocator, "project/{s}", .{name}); + defer testing.allocator.free(sub); + try self.tmp.dir.writeFile(testing.io, .{ .sub_path = sub, .data = content }); + } + + fn deinit(self: *TmpLayers) void { + self.tmp.cleanup(); + } +}; + +fn openTmpSession(arena: Allocator, root: []const u8) !panto.session_manager.SessionManager { + const sessions = try std.fs.path.join(arena, &.{ root, "sessions" }); + return panto.session_manager.SessionManager.init(testing.allocator, testing.io, sessions, "/cwd"); +} + +/// Force the session to flush to disk by appending an assistant entry. +/// (System/user entries buffer in memory until the first assistant entry; +/// a realistic resume only sees what a completed turn persisted.) +fn forceFlush(mgr: *panto.session_manager.SessionManager) !void { + const blocks = try mgr.allocator.alloc(panto.session_manager.DiskContentBlock, 1); + blocks[0] = .{ .text = .{ .text = try mgr.allocator.dupe(u8, "ok") } }; + _ = try mgr.appendMessage( + .{ .role = .assistant, .content = blocks }, + "prov", + "model", + ); +} + +test "seedFresh then no-config-change resume is a no-op" { + const alloc = testing.allocator; + var arena_state = std.heap.ArenaAllocator.init(alloc); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var layers = try TmpLayers.init(arena); + defer layers.deinit(); + try layers.writeProject("SYSTEM.md", "project seed"); + try layers.writeProject("APPEND_SYSTEM.md", "project append"); + const project_dir = try layers.projectDir(arena); + + // Fresh session: seed + persist. + var mgr = try openTmpSession(arena, layers.root); + { + var conv = panto.conversation.Conversation.init(alloc); + defer conv.deinit(); + try seedFreshLayers(arena, testing.io, null, null, project_dir, &conv, &mgr); + } + try forceFlush(&mgr); // persist seed + append (+ assistant) to disk + const entries_on_disk = mgr.getEntries().len; + try testing.expectEqual(@as(usize, 3), entries_on_disk); // seed + append + assistant + const session_file = try arena.dupe(u8, mgr.getSessionFile()); + mgr.deinit(); + + // Resume: reopen the file, rebuild + reconcile against unchanged config + // → no new entries. + var mgr2 = try panto.session_manager.SessionManager.open(alloc, testing.io, session_file); + defer mgr2.deinit(); + var conv2 = try mgr2.rebuildConversation(); + defer conv2.deinit(); + try reconcileResumeLayers(arena, testing.io, null, null, project_dir, &conv2, &mgr2); + try testing.expectEqual(entries_on_disk, mgr2.getEntries().len); +} + +test "resume after config change appends replace + append sequence" { + const alloc = testing.allocator; + var arena_state = std.heap.ArenaAllocator.init(alloc); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var layers = try TmpLayers.init(arena); + defer layers.deinit(); + try layers.writeProject("SYSTEM.md", "old seed"); + const project_dir = try layers.projectDir(arena); + + var mgr = try openTmpSession(arena, layers.root); + { + var conv = panto.conversation.Conversation.init(alloc); + defer conv.deinit(); + try seedFreshLayers(arena, testing.io, null, null, project_dir, &conv, &mgr); + } + try forceFlush(&mgr); // persist old seed (+ assistant) to disk + try testing.expectEqual(@as(usize, 2), mgr.getEntries().len); // seed + assistant + const session_file = try arena.dupe(u8, mgr.getSessionFile()); + mgr.deinit(); + + // Change the config: new seed + a new append. + try layers.writeProject("SYSTEM.md", "new seed"); + try layers.writeProject("APPEND_SYSTEM.md", "new append"); + + var mgr2 = try panto.session_manager.SessionManager.open(alloc, testing.io, session_file); + defer mgr2.deinit(); + var conv2 = try mgr2.rebuildConversation(); + defer conv2.deinit(); + try reconcileResumeLayers(arena, testing.io, null, null, project_dir, &conv2, &mgr2); + + // old seed + assistant + replace(new seed) + append(new append) = 4. + try testing.expectEqual(@as(usize, 4), mgr2.getEntries().len); + + // The effective prompt now reflects only the new config blocks. + const eff = try panto.conversation.effectiveSystemBlocks(arena, conv2.messages.items); + try testing.expectEqual(@as(usize, 2), eff.items.len); + try testing.expectEqualStrings("new seed", eff.items[0]); + try testing.expectEqualStrings("new append", eff.items[1]); + + // A second no-op resume must not append again (anchors to the new + // `replace` window, not the stale original seed). + const after_first = mgr2.getEntries().len; + try reconcileResumeLayers(arena, testing.io, null, null, project_dir, &conv2, &mgr2); + try testing.expectEqual(after_first, mgr2.getEntries().len); +} + +test "Resolved.blocks - seed leads appends" { + const alloc = testing.allocator; + var arena_state = std.heap.ArenaAllocator.init(alloc); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const resolved: Resolved = .{ .seed = "S", .appends = &.{ "P", "U", "B" } }; + const blocks = try resolved.blocks(arena); + try testing.expectEqual(@as(usize, 4), blocks.len); + try testing.expectEqualStrings("S", blocks[0]); + try testing.expectEqualStrings("P", blocks[1]); + try testing.expectEqualStrings("U", blocks[2]); + try testing.expectEqualStrings("B", blocks[3]); +} -- cgit v1.3