@poncho-ai/harness 0.53.0 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +79 -0
- package/dist/index.d.ts +216 -2
- package/dist/index.js +670 -27
- package/package.json +1 -1
- package/src/compaction.ts +206 -13
- package/src/index.ts +18 -0
- package/src/orchestrator/entries-dual-write.ts +265 -0
- package/src/orchestrator/index.ts +7 -0
- package/src/orchestrator/orchestrator.ts +179 -13
- package/src/orchestrator/run-conversation-turn.ts +108 -0
- package/src/state.ts +56 -0
- package/src/storage/engine.ts +18 -0
- package/src/storage/entries.ts +217 -0
- package/src/storage/memory-engine.ts +40 -0
- package/src/storage/schema.ts +30 -0
- package/src/storage/sql-dialect.ts +112 -0
- package/src/storage/store-adapters.ts +8 -0
- package/test/compaction.test.ts +274 -0
- package/test/entries-dual-write.test.ts +172 -0
- package/test/entries-store.test.ts +165 -0
- package/test/entries.test.ts +125 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { Message } from "@poncho-ai/sdk";
|
|
2
|
+
import type { PendingSubagentResult } from "../state.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Append-only conversation entries (Phase 3 substrate).
|
|
6
|
+
*
|
|
7
|
+
* The eventual replacement for the mutable per-conversation JSON blob: a
|
|
8
|
+
* conversation becomes an ordered, append-only list of entries, and the
|
|
9
|
+
* mutable-blob clobber race (two writers serializing a stale whole-blob
|
|
10
|
+
* snapshot over each other — the root cause behind lost subagent results)
|
|
11
|
+
* stops being expressible.
|
|
12
|
+
*
|
|
13
|
+
* This module is intentionally PURE: it defines the entry shapes and the
|
|
14
|
+
* functions that rebuild a conversation's LLM context / display transcript
|
|
15
|
+
* / pending-subagent-results from an entry list. No storage engine, no DB,
|
|
16
|
+
* no wiring into the live run loop yet — so it deploys nothing and is
|
|
17
|
+
* fully unit-testable. The engine implementations (append/read on
|
|
18
|
+
* postgres/sqlite/memory) and the write-site conversions come in later PRs
|
|
19
|
+
* once this rebuild logic is proven.
|
|
20
|
+
*
|
|
21
|
+
* Ordering: every entry carries a monotonic per-conversation `seq`. Entries
|
|
22
|
+
* are assumed sorted by `seq` ascending when passed to the rebuild fns.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
interface BaseEntry {
|
|
26
|
+
/** Stable cross-reference id (uuid). */
|
|
27
|
+
id: string;
|
|
28
|
+
/** Monotonic per-conversation order. */
|
|
29
|
+
seq: number;
|
|
30
|
+
createdAt: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A user-role display message (incl. typed subagent-callback messages). */
|
|
34
|
+
export interface UserMessageEntry extends BaseEntry {
|
|
35
|
+
type: "user_message";
|
|
36
|
+
message: Message;
|
|
37
|
+
turnId: string;
|
|
38
|
+
/** Hidden from the display transcript (e.g. a framed job prompt, an
|
|
39
|
+
* onboarding seed, or an injected subagent-result message). Still part
|
|
40
|
+
* of the record; just not rendered as a chat bubble. */
|
|
41
|
+
hidden?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The final assistant bubble for a completed/cancelled/errored turn. */
|
|
45
|
+
export interface AssistantMessageEntry extends BaseEntry {
|
|
46
|
+
type: "assistant_message";
|
|
47
|
+
message: Message;
|
|
48
|
+
turnId: string;
|
|
49
|
+
runId: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A post-hoc edit to an already-emitted assistant message — replaces the
|
|
53
|
+
* orchestrator/resume "mutate the last assistant message in place" writes
|
|
54
|
+
* with an append. Applied at rebuild time. */
|
|
55
|
+
export interface AssistantAmendmentEntry extends BaseEntry {
|
|
56
|
+
type: "assistant_amendment";
|
|
57
|
+
targetEntryId: string;
|
|
58
|
+
appendText?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One LLM-transcript message (the model-visible form). Appended from the
|
|
62
|
+
* run loop per step — never diffed from an array. */
|
|
63
|
+
export interface HarnessMessageEntry extends BaseEntry {
|
|
64
|
+
type: "harness_message";
|
|
65
|
+
message: Message;
|
|
66
|
+
turnId: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Compaction overlay: nothing is deleted. At rebuild, the LLM context is
|
|
70
|
+
* the latest compaction's `summaryMessage` followed by the harness
|
|
71
|
+
* messages from `firstKeptSeq` onward. */
|
|
72
|
+
export interface CompactionEntry extends BaseEntry {
|
|
73
|
+
type: "compaction";
|
|
74
|
+
summaryMessage: Message;
|
|
75
|
+
firstKeptSeq: number;
|
|
76
|
+
tokensBefore?: number;
|
|
77
|
+
tokensAfter?: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A finished subagent's result arriving for the parent. Pending = a
|
|
81
|
+
* subagent_result whose seq is not listed in any later callback_started. */
|
|
82
|
+
export interface SubagentResultEntry extends BaseEntry {
|
|
83
|
+
type: "subagent_result";
|
|
84
|
+
result: PendingSubagentResult;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Marks which subagent_result entries a callback turn consumed (by seq).
|
|
88
|
+
* Consumption is an append, never a delete. */
|
|
89
|
+
export interface CallbackStartedEntry extends BaseEntry {
|
|
90
|
+
type: "callback_started";
|
|
91
|
+
consumedSeqs: number[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type ConversationEntry =
|
|
95
|
+
| UserMessageEntry
|
|
96
|
+
| AssistantMessageEntry
|
|
97
|
+
| AssistantAmendmentEntry
|
|
98
|
+
| HarnessMessageEntry
|
|
99
|
+
| CompactionEntry
|
|
100
|
+
| SubagentResultEntry
|
|
101
|
+
| CallbackStartedEntry;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* An entry to append, before the engine assigns `seq` and `createdAt`. This
|
|
105
|
+
* is a DISTRIBUTIVE omit — `Omit<ConversationEntry, K>` over a union would
|
|
106
|
+
* collapse to only the keys common to every member (dropping `message`,
|
|
107
|
+
* `summaryMessage`, etc.), so we distribute over the union with a
|
|
108
|
+
* conditional type to omit those fields from each member individually.
|
|
109
|
+
*/
|
|
110
|
+
export type NewConversationEntry = ConversationEntry extends infer T
|
|
111
|
+
? T extends ConversationEntry
|
|
112
|
+
? Omit<T, "seq" | "createdAt">
|
|
113
|
+
: never
|
|
114
|
+
: never;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Rebuild the LLM-visible message context from the entry log.
|
|
118
|
+
*
|
|
119
|
+
* If a compaction overlay exists, the context is its summary message
|
|
120
|
+
* followed by every harness message with seq >= firstKeptSeq (a later
|
|
121
|
+
* compaction's firstKeptSeq can point at an earlier summary that was
|
|
122
|
+
* itself appended as a harness message, so layered compactions just work).
|
|
123
|
+
* With no compaction, it's every harness message in order.
|
|
124
|
+
*/
|
|
125
|
+
export function buildLlmContext(entries: ConversationEntry[]): Message[] {
|
|
126
|
+
let latestCompaction: CompactionEntry | undefined;
|
|
127
|
+
for (const e of entries) {
|
|
128
|
+
if (e.type === "compaction" && (!latestCompaction || e.seq > latestCompaction.seq)) {
|
|
129
|
+
latestCompaction = e;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const harnessMsgs = entries.filter(
|
|
134
|
+
(e): e is HarnessMessageEntry => e.type === "harness_message",
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (latestCompaction) {
|
|
138
|
+
const kept = harnessMsgs
|
|
139
|
+
.filter((e) => e.seq >= latestCompaction!.firstKeptSeq)
|
|
140
|
+
.map((e) => e.message);
|
|
141
|
+
return [latestCompaction.summaryMessage, ...kept];
|
|
142
|
+
}
|
|
143
|
+
return harnessMsgs.map((e) => e.message);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface DisplaySnapshot {
|
|
147
|
+
messages: Message[];
|
|
148
|
+
/** Total display messages available (for pagination UIs). */
|
|
149
|
+
totalMessages: number;
|
|
150
|
+
/** seq of the first message returned (a `beforeSeq` pagination cursor). */
|
|
151
|
+
headSeq: number | null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Rebuild the display transcript (the user-visible chat) from the entry
|
|
156
|
+
* log, returning the trailing `tailN` messages. Amendments are folded into
|
|
157
|
+
* their target assistant message; hidden user messages are dropped.
|
|
158
|
+
*/
|
|
159
|
+
export function buildDisplaySnapshot(
|
|
160
|
+
entries: ConversationEntry[],
|
|
161
|
+
tailN: number,
|
|
162
|
+
): DisplaySnapshot {
|
|
163
|
+
const amendmentsByTarget = new Map<string, AssistantAmendmentEntry[]>();
|
|
164
|
+
for (const e of entries) {
|
|
165
|
+
if (e.type === "assistant_amendment") {
|
|
166
|
+
const list = amendmentsByTarget.get(e.targetEntryId) ?? [];
|
|
167
|
+
list.push(e);
|
|
168
|
+
amendmentsByTarget.set(e.targetEntryId, list);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const built: { seq: number; message: Message }[] = [];
|
|
173
|
+
for (const e of entries) {
|
|
174
|
+
if (e.type === "user_message") {
|
|
175
|
+
if (e.hidden) continue;
|
|
176
|
+
built.push({ seq: e.seq, message: e.message });
|
|
177
|
+
} else if (e.type === "assistant_message") {
|
|
178
|
+
let content = typeof e.message.content === "string" ? e.message.content : "";
|
|
179
|
+
const amendments = amendmentsByTarget.get(e.id);
|
|
180
|
+
if (amendments) {
|
|
181
|
+
for (const a of amendments.sort((x, y) => x.seq - y.seq)) {
|
|
182
|
+
if (a.appendText) content += a.appendText;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
built.push({ seq: e.seq, message: { ...e.message, content } });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const total = built.length;
|
|
190
|
+
const tail = tailN >= total ? built : built.slice(total - tailN);
|
|
191
|
+
return {
|
|
192
|
+
messages: tail.map((b) => b.message),
|
|
193
|
+
totalMessages: total,
|
|
194
|
+
headSeq: tail.length > 0 ? tail[0]!.seq : null,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Subagent results that have arrived but not yet been consumed by a
|
|
200
|
+
* callback turn — the append-only replacement for the mutable
|
|
201
|
+
* `pendingSubagentResults` array. A result is pending unless a later
|
|
202
|
+
* callback_started lists its seq in `consumedSeqs`.
|
|
203
|
+
*/
|
|
204
|
+
export function getPendingSubagentResults(
|
|
205
|
+
entries: ConversationEntry[],
|
|
206
|
+
): PendingSubagentResult[] {
|
|
207
|
+
const consumed = new Set<number>();
|
|
208
|
+
for (const e of entries) {
|
|
209
|
+
if (e.type === "callback_started") {
|
|
210
|
+
for (const s of e.consumedSeqs) consumed.add(s);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return entries
|
|
214
|
+
.filter((e): e is SubagentResultEntry => e.type === "subagent_result")
|
|
215
|
+
.filter((e) => !consumed.has(e.seq))
|
|
216
|
+
.map((e) => e.result);
|
|
217
|
+
}
|
|
@@ -14,6 +14,7 @@ import type { MainMemory } from "../memory.js";
|
|
|
14
14
|
import type { TodoItem } from "../todo-tools.js";
|
|
15
15
|
import type { Reminder, ReminderCreateInput, ReminderStatus } from "../reminder-store.js";
|
|
16
16
|
import type { StorageEngine, VfsDirEntry, VfsStat } from "./engine.js";
|
|
17
|
+
import type { ConversationEntry, NewConversationEntry } from "./entries.js";
|
|
17
18
|
|
|
18
19
|
// ---------------------------------------------------------------------------
|
|
19
20
|
// Internal VFS entry type
|
|
@@ -59,6 +60,8 @@ export class InMemoryEngine implements StorageEngine {
|
|
|
59
60
|
|
|
60
61
|
// Conversation data
|
|
61
62
|
private convs = new Map<string, Conversation>();
|
|
63
|
+
// Append-only conversation entries (Phase 3 substrate)
|
|
64
|
+
private entries = new Map<string, ConversationEntry[]>();
|
|
62
65
|
// Memory data
|
|
63
66
|
private mem = new Map<string, MainMemory>();
|
|
64
67
|
// Todos data
|
|
@@ -239,6 +242,43 @@ export class InMemoryEngine implements StorageEngine {
|
|
|
239
242
|
results.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
240
243
|
return results;
|
|
241
244
|
},
|
|
245
|
+
|
|
246
|
+
appendEntries: async (
|
|
247
|
+
conversationId: string,
|
|
248
|
+
_agentId: string,
|
|
249
|
+
_tenantId: string | null,
|
|
250
|
+
entries: NewConversationEntry[],
|
|
251
|
+
): Promise<ConversationEntry[]> => {
|
|
252
|
+
const list = this.entries.get(conversationId) ?? [];
|
|
253
|
+
// seq is per-conversation: max existing seq + 1, then consecutive.
|
|
254
|
+
let nextSeq = list.reduce((max, e) => (e.seq > max ? e.seq : max), 0) + 1;
|
|
255
|
+
const now = Date.now();
|
|
256
|
+
const stored: ConversationEntry[] = entries.map(
|
|
257
|
+
(e) => ({ ...e, seq: nextSeq++, createdAt: now }) as ConversationEntry,
|
|
258
|
+
);
|
|
259
|
+
this.entries.set(conversationId, [...list, ...stored]);
|
|
260
|
+
return stored;
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
readEntries: async (
|
|
264
|
+
conversationId: string,
|
|
265
|
+
opts?: { types?: string[]; afterSeq?: number; limit?: number },
|
|
266
|
+
): Promise<ConversationEntry[]> => {
|
|
267
|
+
let list = (this.entries.get(conversationId) ?? [])
|
|
268
|
+
.slice()
|
|
269
|
+
.sort((a, b) => a.seq - b.seq);
|
|
270
|
+
if (opts?.types && opts.types.length > 0) {
|
|
271
|
+
const allowed = new Set(opts.types);
|
|
272
|
+
list = list.filter((e) => allowed.has(e.type));
|
|
273
|
+
}
|
|
274
|
+
if (typeof opts?.afterSeq === "number") {
|
|
275
|
+
list = list.filter((e) => e.seq > opts.afterSeq!);
|
|
276
|
+
}
|
|
277
|
+
if (typeof opts?.limit === "number") {
|
|
278
|
+
list = list.slice(0, opts.limit);
|
|
279
|
+
}
|
|
280
|
+
return list;
|
|
281
|
+
},
|
|
242
282
|
};
|
|
243
283
|
|
|
244
284
|
// -----------------------------------------------------------------------
|
package/src/storage/schema.ts
CHANGED
|
@@ -214,4 +214,34 @@ export const migrations: Migration[] = [
|
|
|
214
214
|
];
|
|
215
215
|
},
|
|
216
216
|
},
|
|
217
|
+
{
|
|
218
|
+
version: 8,
|
|
219
|
+
name: "conversation_entries",
|
|
220
|
+
// Append-only conversation log (Phase 3 substrate). Additive: no
|
|
221
|
+
// existing table or behavior changes. `seq` is a per-conversation
|
|
222
|
+
// monotonic order assigned by the application (NOT an autoincrement
|
|
223
|
+
// serial), so the same seq space restarts at 1 for every conversation.
|
|
224
|
+
// The UNIQUE (conversation_id, seq) constraint backstops the
|
|
225
|
+
// app-assigned ordering against concurrent writers.
|
|
226
|
+
up: (d) => {
|
|
227
|
+
const jsonType = d === "sqlite" ? "TEXT" : "JSONB";
|
|
228
|
+
const tsDefault = d === "sqlite" ? "datetime('now')" : "NOW()";
|
|
229
|
+
const autoTs = `DEFAULT (${tsDefault})`;
|
|
230
|
+
return [
|
|
231
|
+
`CREATE TABLE IF NOT EXISTS conversation_entries (
|
|
232
|
+
seq INTEGER NOT NULL,
|
|
233
|
+
id TEXT NOT NULL UNIQUE,
|
|
234
|
+
agent_id TEXT NOT NULL,
|
|
235
|
+
tenant_id TEXT NOT NULL DEFAULT '__default__',
|
|
236
|
+
conversation_id TEXT NOT NULL,
|
|
237
|
+
type TEXT NOT NULL,
|
|
238
|
+
payload ${jsonType} NOT NULL,
|
|
239
|
+
created_at TIMESTAMP ${autoTs},
|
|
240
|
+
UNIQUE (conversation_id, seq)
|
|
241
|
+
)`,
|
|
242
|
+
`CREATE INDEX IF NOT EXISTS idx_conversation_entries_seq
|
|
243
|
+
ON conversation_entries (conversation_id, seq)`,
|
|
244
|
+
];
|
|
245
|
+
},
|
|
246
|
+
},
|
|
217
247
|
];
|
|
@@ -22,6 +22,7 @@ import type { MainMemory } from "../memory.js";
|
|
|
22
22
|
import type { TodoItem } from "../todo-tools.js";
|
|
23
23
|
import type { Reminder, ReminderCreateInput, ReminderStatus } from "../reminder-store.js";
|
|
24
24
|
import type { StorageEngine, VfsDirEntry, VfsStat } from "./engine.js";
|
|
25
|
+
import type { ConversationEntry, NewConversationEntry } from "./entries.js";
|
|
25
26
|
import { type DialectTag, migrations } from "./schema.js";
|
|
26
27
|
|
|
27
28
|
// ---------------------------------------------------------------------------
|
|
@@ -628,6 +629,117 @@ export abstract class SqlStorageEngine implements StorageEngine {
|
|
|
628
629
|
]);
|
|
629
630
|
return rows.map((r) => this.rowToSummary(r));
|
|
630
631
|
},
|
|
632
|
+
|
|
633
|
+
appendEntries: async (
|
|
634
|
+
conversationId: string,
|
|
635
|
+
agentId: string,
|
|
636
|
+
tenantId: string | null,
|
|
637
|
+
entries: NewConversationEntry[],
|
|
638
|
+
): Promise<ConversationEntry[]> => {
|
|
639
|
+
if (entries.length === 0) return [];
|
|
640
|
+
const tid = normalizeTenant(tenantId);
|
|
641
|
+
|
|
642
|
+
// CONCURRENCY: seq is assigned by the app as COALESCE(MAX(seq),0)+1 per
|
|
643
|
+
// conversation, inside a transaction. Two concurrent writers can read
|
|
644
|
+
// the same MAX and collide on the UNIQUE (conversation_id, seq)
|
|
645
|
+
// constraint; we let the loser's transaction fail and retry the whole
|
|
646
|
+
// compute-and-insert up to 3 times. This MAX+1+retry scheme is correct
|
|
647
|
+
// for the modest concurrency here (a single conversation rarely has
|
|
648
|
+
// simultaneous appenders). A later hardening — a per-conversation
|
|
649
|
+
// advisory lock — can replace it before any read path depends on these
|
|
650
|
+
// entries.
|
|
651
|
+
const maxAttempts = 3;
|
|
652
|
+
let lastErr: unknown;
|
|
653
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
654
|
+
const stored: ConversationEntry[] = [];
|
|
655
|
+
try {
|
|
656
|
+
await this.executor.transaction(async () => {
|
|
657
|
+
stored.length = 0; // reset on retry within the same closure
|
|
658
|
+
const row = await this.executor.get<{ max_seq: number | null }>(
|
|
659
|
+
rewrite(
|
|
660
|
+
"SELECT MAX(seq) AS max_seq FROM conversation_entries WHERE conversation_id = $1",
|
|
661
|
+
this.dialect,
|
|
662
|
+
),
|
|
663
|
+
[conversationId],
|
|
664
|
+
);
|
|
665
|
+
let nextSeq = Number(row?.max_seq ?? 0) + 1;
|
|
666
|
+
const now = Date.now();
|
|
667
|
+
for (const entry of entries) {
|
|
668
|
+
const seq = nextSeq++;
|
|
669
|
+
const createdAt = now;
|
|
670
|
+
// Persist the full entry object (minus seq/createdAt, which are
|
|
671
|
+
// columns) as the JSON payload. seq, id, created_at are columns.
|
|
672
|
+
const payload = JSON.stringify(entry);
|
|
673
|
+
await this.executor.run(
|
|
674
|
+
rewrite(
|
|
675
|
+
`INSERT INTO conversation_entries
|
|
676
|
+
(seq, id, agent_id, tenant_id, conversation_id, type, payload, created_at)
|
|
677
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
678
|
+
this.dialect,
|
|
679
|
+
),
|
|
680
|
+
[
|
|
681
|
+
seq,
|
|
682
|
+
entry.id,
|
|
683
|
+
agentId,
|
|
684
|
+
tid,
|
|
685
|
+
conversationId,
|
|
686
|
+
entry.type,
|
|
687
|
+
payload,
|
|
688
|
+
new Date(createdAt).toISOString(),
|
|
689
|
+
],
|
|
690
|
+
);
|
|
691
|
+
stored.push({ ...entry, seq, createdAt } as ConversationEntry);
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
return stored;
|
|
695
|
+
} catch (err) {
|
|
696
|
+
lastErr = err;
|
|
697
|
+
// Retry on any failure (the likely cause is a UNIQUE collision from
|
|
698
|
+
// a concurrent writer); the next attempt re-reads MAX(seq).
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
throw lastErr;
|
|
702
|
+
},
|
|
703
|
+
|
|
704
|
+
readEntries: async (
|
|
705
|
+
conversationId: string,
|
|
706
|
+
opts?: { types?: string[]; afterSeq?: number; limit?: number },
|
|
707
|
+
): Promise<ConversationEntry[]> => {
|
|
708
|
+
const params: unknown[] = [conversationId];
|
|
709
|
+
let sql = "SELECT seq, id, payload, created_at FROM conversation_entries WHERE conversation_id = $1";
|
|
710
|
+
if (opts?.types && opts.types.length > 0) {
|
|
711
|
+
const placeholders = opts.types.map(
|
|
712
|
+
(_t, i) => `$${params.length + 1 + i}`,
|
|
713
|
+
);
|
|
714
|
+
sql += ` AND type IN (${placeholders.join(", ")})`;
|
|
715
|
+
params.push(...opts.types);
|
|
716
|
+
}
|
|
717
|
+
if (typeof opts?.afterSeq === "number") {
|
|
718
|
+
sql += ` AND seq > $${params.length + 1}`;
|
|
719
|
+
params.push(opts.afterSeq);
|
|
720
|
+
}
|
|
721
|
+
sql += " ORDER BY seq ASC";
|
|
722
|
+
if (typeof opts?.limit === "number") {
|
|
723
|
+
sql += ` LIMIT $${params.length + 1}`;
|
|
724
|
+
params.push(opts.limit);
|
|
725
|
+
}
|
|
726
|
+
const rows = await this.executor.all<{
|
|
727
|
+
seq: number;
|
|
728
|
+
id: string;
|
|
729
|
+
payload: unknown;
|
|
730
|
+
created_at: string;
|
|
731
|
+
}>(rewrite(sql, this.dialect), params);
|
|
732
|
+
return rows.map((r) => {
|
|
733
|
+
const parsed =
|
|
734
|
+
typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload;
|
|
735
|
+
return {
|
|
736
|
+
...parsed,
|
|
737
|
+
id: r.id,
|
|
738
|
+
seq: Number(r.seq),
|
|
739
|
+
createdAt: new Date(r.created_at).getTime(),
|
|
740
|
+
} as ConversationEntry;
|
|
741
|
+
});
|
|
742
|
+
},
|
|
631
743
|
};
|
|
632
744
|
|
|
633
745
|
// -----------------------------------------------------------------------
|
|
@@ -58,6 +58,14 @@ export function createConversationStoreFromEngine(
|
|
|
58
58
|
engine.conversations.clearCallbackLock(conversationId),
|
|
59
59
|
listThreads: (parentConversationId: string) =>
|
|
60
60
|
engine.conversations.listThreads(parentConversationId),
|
|
61
|
+
appendEntries: (
|
|
62
|
+
conversationId: string,
|
|
63
|
+
agentId: string,
|
|
64
|
+
tenantId: string | null,
|
|
65
|
+
entries,
|
|
66
|
+
) => engine.conversations.appendEntries(conversationId, agentId, tenantId, entries),
|
|
67
|
+
readEntries: (conversationId: string, opts) =>
|
|
68
|
+
engine.conversations.readEntries(conversationId, opts),
|
|
61
69
|
};
|
|
62
70
|
}
|
|
63
71
|
|