summaryrefslogtreecommitdiff
path: root/src/session_persist.zig
blob: d5349bc97bc6bfeea8155f723df8faba9e5ea70e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
//! Session-log persistence helpers shared between the REPL loop
//! (`main.zig`) and slash-command handlers (e.g. `compaction.zig`).
//!
//! These were previously private to `main.zig`. They were lifted into
//! their own module so that command handlers living outside the root
//! source file can reuse them without depending on `main.zig` (which,
//! being the executable root, can't be imported as a normal module).

const std = @import("std");
const panto = @import("panto");

/// Append a single user-prompt message to the session log.
pub fn appendUserPromptToSession(
    alloc: std.mem.Allocator,
    mgr: *panto.session_manager.SessionManager,
    text: []const u8,
    provider: []const u8,
    model: []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 = .user, .content = blocks },
        provider,
        model,
    );
}

/// After the agent loop has driven a turn to completion, persist every
/// new in-memory message at index `>= start_index` to the session log.
///
/// Each in-memory message is mapped to disk:
///   - assistant → assistant entry with provider/model/stop_reason metadata
///     and Usage (from `per_message_usage`, one slot per assistant message in
///     completion order).
///   - user (with ToolResult blocks) → user entry stamped with provider/model.
///
/// `stop_reason` is recorded as `"stop"` for now; surfacing the real wire
/// value requires plumbing it through the Receiver vtable (future work,
/// separate from token plumbing).
pub fn persistTurn(
    alloc: std.mem.Allocator,
    mgr: *panto.session_manager.SessionManager,
    conv: *const panto.conversation.Conversation,
    start_index: usize,
    provider: []const u8,
    model: []const u8,
    per_message_usage: []const ?panto.session_manager.Usage,
) !void {
    var batch_messages: std.ArrayList(panto.session_manager.DiskMessage) = .empty;
    defer batch_messages.deinit(alloc);
    var batch_providers: std.ArrayList(?[]const u8) = .empty;
    defer batch_providers.deinit(alloc);
    var batch_models: std.ArrayList(?[]const u8) = .empty;
    defer batch_models.deinit(alloc);

    var i = start_index;
    var assistant_seen: usize = 0;
    while (i < conv.messages.items.len) : (i += 1) {
        const msg = conv.messages.items[i];
        const blocks = try alloc.alloc(panto.session_manager.DiskContentBlock, msg.content.items.len);
        var allocated: usize = 0;
        errdefer {
            for (blocks[0..allocated]) |b| b.deinit(alloc);
            alloc.free(blocks);
        }
        for (msg.content.items) |block| {
            blocks[allocated] = try panto.session.contentBlockToDisk(alloc, block);
            allocated += 1;
        }
        if (msg.role == .assistant and hasToolUseWithoutFollowingResults(conv, i)) {
            for (blocks[0..allocated]) |b| b.deinit(alloc);
            alloc.free(blocks);
            continue;
        }
        switch (msg.role) {
            .system => {
                // Mid-turn system messages aren't a thing in pantograph today.
                // Treat as harmless and persist verbatim, sans stamps.
                try batch_messages.append(alloc, .{ .role = .system, .content = blocks });
                try batch_providers.append(alloc, null);
                try batch_models.append(alloc, null);
            },
            .user => {
                try batch_messages.append(alloc, .{ .role = .user, .content = blocks });
                try batch_providers.append(alloc, provider);
                try batch_models.append(alloc, model);
            },
            .assistant => {
                // Pair this assistant message with its usage, if the
                // receiver captured one for this position. (A turn
                // ending in a stream error can have fewer usage entries
                // than assistant messages; treat as null and move on.)
                const usage: ?panto.session_manager.Usage = if (assistant_seen < per_message_usage.len)
                    per_message_usage[assistant_seen]
                else
                    null;
                assistant_seen += 1;
                try batch_messages.append(alloc, .{
                    .role = .assistant,
                    .content = blocks,
                    .provider = try alloc.dupe(u8, provider),
                    .model = try alloc.dupe(u8, model),
                    .stop_reason = try alloc.dupe(u8, "stop"),
                    .usage = usage,
                });
                try batch_providers.append(alloc, null);
                try batch_models.append(alloc, null);
            },
        }
    }
    try mgr.appendMessagesAtomic(batch_messages.items, batch_providers.items, batch_models.items);
}

/// Persist a compaction to the session log. The agent rewrote the
/// in-memory conversation to `[system..., summary, kept-suffix...]`; the
/// superseded prefix stays on disk untouched. We append everything from
/// the compaction summary onward as fresh entries: the summary message
/// (carrying the `compactionSummary` block) followed by the duplicated
/// kept-verbatim suffix. On replay, the latest summary resets effective
/// context, so the duplicated suffix is what survives.
///
/// Usage is not threaded through here: the duplicated entries are replays
/// of already-sized turns, and the summary itself isn't an assistant turn.
pub fn persistCompaction(
    alloc: std.mem.Allocator,
    mgr: *panto.session_manager.SessionManager,
    conv: *const panto.conversation.Conversation,
    provider: []const u8,
    model: []const u8,
) !void {
    const start = panto.conversation.latestCompactionIndex(conv.messages.items) orelse return;
    try persistTurn(alloc, mgr, conv, start, provider, model, &.{});
}

pub fn hasToolUseWithoutFollowingResults(conv: *const panto.conversation.Conversation, index: usize) bool {
    const msg = conv.messages.items[index];
    for (msg.content.items) |block| {
        if (block != .ToolUse) continue;
        if (index + 1 >= conv.messages.items.len) return true;
        const next = conv.messages.items[index + 1];
        if (next.role != .user) return true;
        var found = false;
        for (next.content.items) |next_block| {
            if (next_block == .ToolResult and std.mem.eql(u8, next_block.ToolResult.tool_use_id, block.ToolUse.id)) {
                found = true;
                break;
            }
        }
        if (!found) return true;
    }
    return false;
}