@poncho-ai/harness 0.37.2 → 0.39.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 +216 -0
- package/dist/index.d.ts +397 -34
- package/dist/index.js +2228 -130
- package/package.json +2 -2
- package/src/harness.ts +48 -72
- package/src/index.ts +1 -0
- package/src/local-tools.ts +4 -3
- package/src/mcp.ts +9 -6
- package/src/orchestrator/continuation.ts +13 -0
- package/src/orchestrator/history.ts +69 -0
- package/src/orchestrator/index.ts +54 -0
- package/src/orchestrator/orchestrator.ts +1535 -0
- package/src/orchestrator/subagents.ts +27 -0
- package/src/orchestrator/turn.ts +367 -0
- package/src/reminder-store.ts +183 -16
- package/src/reminder-tools.ts +102 -6
- package/src/state.ts +127 -3
- package/src/storage/engine.ts +29 -10
- package/src/storage/memory-engine.ts +74 -10
- package/src/storage/postgres-engine.ts +1 -0
- package/src/storage/schema.ts +21 -0
- package/src/storage/sql-dialect.ts +294 -23
- package/src/storage/sqlite-engine.ts +1 -0
- package/src/storage/store-adapters.ts +17 -11
- package/src/telemetry.ts +10 -7
- package/src/upload-store.ts +7 -4
- package/src/vfs/bash-manager.ts +16 -2
- package/src/vfs/edit-file-tool.ts +9 -8
- package/src/vfs/read-file-tool.ts +14 -15
- package/src/vfs/write-file-tool.ts +6 -5
- package/test/orchestrator.test.ts +176 -0
- package/test/reminder-store.test.ts +193 -4
- package/test/state.test.ts +21 -0
- package/test/storage-engine.test.ts +80 -0
|
@@ -8,14 +8,19 @@
|
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
|
|
10
10
|
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { createLogger } from "@poncho-ai/sdk";
|
|
11
12
|
import type {
|
|
12
13
|
Conversation,
|
|
14
|
+
ConversationCreateInit,
|
|
15
|
+
ConversationStatusSnapshot,
|
|
13
16
|
ConversationSummary,
|
|
14
17
|
PendingSubagentResult,
|
|
15
18
|
} from "../state.js";
|
|
19
|
+
|
|
20
|
+
const egressLog = createLogger("egress");
|
|
16
21
|
import type { MainMemory } from "../memory.js";
|
|
17
22
|
import type { TodoItem } from "../todo-tools.js";
|
|
18
|
-
import type { Reminder } from "../reminder-store.js";
|
|
23
|
+
import type { Reminder, ReminderCreateInput, ReminderStatus } from "../reminder-store.js";
|
|
19
24
|
import type { StorageEngine, VfsDirEntry, VfsStat } from "./engine.js";
|
|
20
25
|
import { type DialectTag, migrations } from "./schema.js";
|
|
21
26
|
|
|
@@ -104,10 +109,91 @@ const rewrite = (sql: string, dialect: Dialect): string => {
|
|
|
104
109
|
// SqlStorageEngine base class
|
|
105
110
|
// ---------------------------------------------------------------------------
|
|
106
111
|
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Conversation egress meter — tracks estimated bytes read from / written to
|
|
114
|
+
// the conversations table per method. Enable periodic logging by setting
|
|
115
|
+
// PONCHO_LOG_EGRESS=1 in the environment.
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
interface EgressBucket {
|
|
119
|
+
calls: number;
|
|
120
|
+
bytes: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
class ConversationEgressMeter {
|
|
124
|
+
readonly read: Record<string, EgressBucket> = {};
|
|
125
|
+
readonly write: Record<string, EgressBucket> = {};
|
|
126
|
+
private lastLogAt = Date.now();
|
|
127
|
+
private readonly logIntervalMs: number;
|
|
128
|
+
private readonly enabled: boolean;
|
|
129
|
+
|
|
130
|
+
constructor(logIntervalMs = 60_000) {
|
|
131
|
+
this.logIntervalMs = logIntervalMs;
|
|
132
|
+
this.enabled = process.env.PONCHO_LOG_EGRESS === "1";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
trackRead(method: string, bytes: number): void {
|
|
136
|
+
const b = (this.read[method] ??= { calls: 0, bytes: 0 });
|
|
137
|
+
b.calls += 1;
|
|
138
|
+
b.bytes += bytes;
|
|
139
|
+
this.maybeLog();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
trackWrite(method: string, bytes: number): void {
|
|
143
|
+
const b = (this.write[method] ??= { calls: 0, bytes: 0 });
|
|
144
|
+
b.calls += 1;
|
|
145
|
+
b.bytes += bytes;
|
|
146
|
+
this.maybeLog();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private maybeLog(): void {
|
|
150
|
+
if (!this.enabled) return;
|
|
151
|
+
const now = Date.now();
|
|
152
|
+
if (now - this.lastLogAt < this.logIntervalMs) return;
|
|
153
|
+
this.lastLogAt = now;
|
|
154
|
+
this.flush();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
flush(): void {
|
|
158
|
+
if (!this.enabled) return;
|
|
159
|
+
const fmt = (buckets: Record<string, EgressBucket>) =>
|
|
160
|
+
Object.entries(buckets)
|
|
161
|
+
.filter(([, b]) => b.calls > 0)
|
|
162
|
+
.map(([m, b]) => `${m}=${b.calls}calls/${fmtBytes(b.bytes)}`)
|
|
163
|
+
.join(", ");
|
|
164
|
+
const r = fmt(this.read);
|
|
165
|
+
const w = fmt(this.write);
|
|
166
|
+
if (r || w) {
|
|
167
|
+
egressLog.debug(`read: ${r || "(none)"} | write: ${w || "(none)"}`);
|
|
168
|
+
}
|
|
169
|
+
// Reset after logging.
|
|
170
|
+
for (const b of Object.values(this.read)) { b.calls = 0; b.bytes = 0; }
|
|
171
|
+
for (const b of Object.values(this.write)) { b.calls = 0; b.bytes = 0; }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const fmtBytes = (n: number): string => {
|
|
176
|
+
if (n < 1024) return `${n}B`;
|
|
177
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
|
|
178
|
+
return `${(n / (1024 * 1024)).toFixed(2)}MB`;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** Estimate the byte size of a string-ish value (JSON column from the DB). */
|
|
182
|
+
const colBytes = (v: unknown): number => {
|
|
183
|
+
if (v == null) return 0;
|
|
184
|
+
if (typeof v === "string") return v.length;
|
|
185
|
+
if (Buffer.isBuffer(v)) return v.byteLength;
|
|
186
|
+
// JSONB columns in node-postgres come back as parsed objects — estimate
|
|
187
|
+
// by re-stringifying. This is only called on the measured hot paths, so
|
|
188
|
+
// the overhead is acceptable.
|
|
189
|
+
return JSON.stringify(v).length;
|
|
190
|
+
};
|
|
191
|
+
|
|
107
192
|
export abstract class SqlStorageEngine implements StorageEngine {
|
|
108
193
|
protected readonly dialect: Dialect;
|
|
109
194
|
protected readonly agentId: string;
|
|
110
195
|
protected abstract readonly executor: QueryExecutor;
|
|
196
|
+
protected readonly egressMeter = new ConversationEgressMeter();
|
|
111
197
|
|
|
112
198
|
constructor(dialect: Dialect, agentId: string) {
|
|
113
199
|
this.dialect = dialect;
|
|
@@ -125,6 +211,11 @@ export abstract class SqlStorageEngine implements StorageEngine {
|
|
|
125
211
|
|
|
126
212
|
abstract close(): Promise<void>;
|
|
127
213
|
|
|
214
|
+
/** Flush egress stats before shutdown. Subclasses should call super. */
|
|
215
|
+
protected flushEgressStats(): void {
|
|
216
|
+
this.egressMeter.flush();
|
|
217
|
+
}
|
|
218
|
+
|
|
128
219
|
/** Hook for subclass-specific setup (e.g. WAL mode). */
|
|
129
220
|
protected async onBeforeInit(): Promise<void> {}
|
|
130
221
|
|
|
@@ -189,7 +280,7 @@ export abstract class SqlStorageEngine implements StorageEngine {
|
|
|
189
280
|
const filterTenant = tenantId !== undefined;
|
|
190
281
|
const params: unknown[] = [this.agentId];
|
|
191
282
|
let sql = `SELECT id, title, updated_at, created_at, owner_id, tenant_id,
|
|
192
|
-
message_count, parent_conversation_id,
|
|
283
|
+
message_count, parent_conversation_id, parent_message_id,
|
|
193
284
|
has_pending_approvals, channel_meta
|
|
194
285
|
FROM conversations WHERE agent_id = $1`;
|
|
195
286
|
if (filterTenant) {
|
|
@@ -207,6 +298,83 @@ export abstract class SqlStorageEngine implements StorageEngine {
|
|
|
207
298
|
},
|
|
208
299
|
|
|
209
300
|
get: async (conversationId: string): Promise<Conversation | undefined> => {
|
|
301
|
+
// Skip tool_result_archive — unbounded blob, only needed on run-entry
|
|
302
|
+
// paths which must use getWithArchive() instead.
|
|
303
|
+
const row = await this.executor.get<{
|
|
304
|
+
data: unknown;
|
|
305
|
+
harness_messages: unknown;
|
|
306
|
+
continuation_messages: unknown;
|
|
307
|
+
}>(
|
|
308
|
+
rewrite("SELECT data, harness_messages, continuation_messages FROM conversations WHERE id = $1 AND agent_id = $2", this.dialect),
|
|
309
|
+
[conversationId, this.agentId],
|
|
310
|
+
);
|
|
311
|
+
if (!row) return undefined;
|
|
312
|
+
this.egressMeter.trackRead("get",
|
|
313
|
+
colBytes(row.data) + colBytes(row.harness_messages) + colBytes(row.continuation_messages));
|
|
314
|
+
const conv = this.parseConversation(row.data);
|
|
315
|
+
if (row.harness_messages) {
|
|
316
|
+
conv._harnessMessages =
|
|
317
|
+
typeof row.harness_messages === "string"
|
|
318
|
+
? JSON.parse(row.harness_messages)
|
|
319
|
+
: row.harness_messages;
|
|
320
|
+
}
|
|
321
|
+
if (row.continuation_messages) {
|
|
322
|
+
conv._continuationMessages =
|
|
323
|
+
typeof row.continuation_messages === "string"
|
|
324
|
+
? JSON.parse(row.continuation_messages)
|
|
325
|
+
: row.continuation_messages;
|
|
326
|
+
}
|
|
327
|
+
return conv;
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
getStatusSnapshot: async (
|
|
331
|
+
conversationId: string,
|
|
332
|
+
): Promise<ConversationStatusSnapshot | undefined> => {
|
|
333
|
+
// Column-only read. runStatus lives inside the `data` JSON; extract it
|
|
334
|
+
// with a dialect-specific JSON path expression rather than pulling the
|
|
335
|
+
// whole blob.
|
|
336
|
+
const runStatusExpr = this.dialect.tag === "sqlite"
|
|
337
|
+
? "json_extract(data, '$.runStatus')"
|
|
338
|
+
: "data->>'runStatus'";
|
|
339
|
+
const row = await this.executor.get<{
|
|
340
|
+
updated_at: string;
|
|
341
|
+
message_count: number;
|
|
342
|
+
has_pending_approvals: number | boolean;
|
|
343
|
+
parent_conversation_id: string | null;
|
|
344
|
+
owner_id: string;
|
|
345
|
+
tenant_id: string | null;
|
|
346
|
+
has_continuation_messages: number | boolean;
|
|
347
|
+
run_status: string | null;
|
|
348
|
+
}>(
|
|
349
|
+
rewrite(
|
|
350
|
+
`SELECT updated_at, message_count, has_pending_approvals,
|
|
351
|
+
parent_conversation_id, owner_id, tenant_id,
|
|
352
|
+
(continuation_messages IS NOT NULL) AS has_continuation_messages,
|
|
353
|
+
${runStatusExpr} AS run_status
|
|
354
|
+
FROM conversations WHERE id = $1 AND agent_id = $2`,
|
|
355
|
+
this.dialect,
|
|
356
|
+
),
|
|
357
|
+
[conversationId, this.agentId],
|
|
358
|
+
);
|
|
359
|
+
if (!row) return undefined;
|
|
360
|
+
this.egressMeter.trackRead("getStatusSnapshot", 200); // fixed-size column read
|
|
361
|
+
const runStatus = row.run_status === "running" || row.run_status === "idle"
|
|
362
|
+
? row.run_status
|
|
363
|
+
: null;
|
|
364
|
+
return {
|
|
365
|
+
conversationId,
|
|
366
|
+
updatedAt: new Date(row.updated_at).getTime(),
|
|
367
|
+
messageCount: row.message_count ?? 0,
|
|
368
|
+
hasPendingApprovals: !!row.has_pending_approvals,
|
|
369
|
+
hasContinuationMessages: !!row.has_continuation_messages,
|
|
370
|
+
parentConversationId: row.parent_conversation_id,
|
|
371
|
+
ownerId: row.owner_id,
|
|
372
|
+
tenantId: row.tenant_id === "__default__" ? null : row.tenant_id,
|
|
373
|
+
runStatus,
|
|
374
|
+
};
|
|
375
|
+
},
|
|
376
|
+
|
|
377
|
+
getWithArchive: async (conversationId: string): Promise<Conversation | undefined> => {
|
|
210
378
|
const row = await this.executor.get<{
|
|
211
379
|
data: unknown;
|
|
212
380
|
tool_result_archive: unknown;
|
|
@@ -217,8 +385,10 @@ export abstract class SqlStorageEngine implements StorageEngine {
|
|
|
217
385
|
[conversationId, this.agentId],
|
|
218
386
|
);
|
|
219
387
|
if (!row) return undefined;
|
|
388
|
+
this.egressMeter.trackRead("getWithArchive",
|
|
389
|
+
colBytes(row.data) + colBytes(row.tool_result_archive) +
|
|
390
|
+
colBytes(row.harness_messages) + colBytes(row.continuation_messages));
|
|
220
391
|
const conv = this.parseConversation(row.data);
|
|
221
|
-
// Rehydrate heavy fields from separate columns
|
|
222
392
|
if (row.tool_result_archive) {
|
|
223
393
|
conv._toolResultArchive =
|
|
224
394
|
typeof row.tool_result_archive === "string"
|
|
@@ -244,24 +414,41 @@ export abstract class SqlStorageEngine implements StorageEngine {
|
|
|
244
414
|
ownerId?: string,
|
|
245
415
|
title?: string,
|
|
246
416
|
tenantId?: string | null,
|
|
417
|
+
init?: ConversationCreateInit,
|
|
247
418
|
): Promise<Conversation> => {
|
|
248
419
|
const id = randomUUID();
|
|
249
420
|
const now = Date.now();
|
|
250
421
|
const conv: Conversation = {
|
|
251
422
|
conversationId: id,
|
|
252
423
|
title: normalizeTitle(title),
|
|
253
|
-
messages: [],
|
|
424
|
+
messages: init?.messages ?? [],
|
|
254
425
|
ownerId: ownerId ?? DEFAULT_OWNER,
|
|
255
426
|
tenantId: tenantId === undefined ? null : tenantId,
|
|
256
427
|
createdAt: now,
|
|
257
428
|
updatedAt: now,
|
|
429
|
+
...(init?.parentConversationId !== undefined
|
|
430
|
+
? { parentConversationId: init.parentConversationId }
|
|
431
|
+
: {}),
|
|
432
|
+
...(init?.parentMessageId !== undefined
|
|
433
|
+
? { parentMessageId: init.parentMessageId }
|
|
434
|
+
: {}),
|
|
435
|
+
...(init?.subagentMeta !== undefined
|
|
436
|
+
? { subagentMeta: init.subagentMeta }
|
|
437
|
+
: {}),
|
|
438
|
+
...(init?.threadMeta !== undefined
|
|
439
|
+
? { threadMeta: init.threadMeta }
|
|
440
|
+
: {}),
|
|
441
|
+
...(init?.channelMeta !== undefined
|
|
442
|
+
? { channelMeta: init.channelMeta }
|
|
443
|
+
: {}),
|
|
258
444
|
};
|
|
259
445
|
const data = JSON.stringify(conv);
|
|
446
|
+
const channelMetaJson = conv.channelMeta ? JSON.stringify(conv.channelMeta) : null;
|
|
260
447
|
await this.executor.run(
|
|
261
448
|
rewrite(
|
|
262
449
|
`INSERT INTO conversations (id, agent_id, tenant_id, owner_id, title, data, message_count, created_at, updated_at,
|
|
263
|
-
parent_conversation_id, has_pending_approvals, channel_meta)
|
|
264
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
|
450
|
+
parent_conversation_id, parent_message_id, has_pending_approvals, channel_meta)
|
|
451
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
|
|
265
452
|
this.dialect,
|
|
266
453
|
),
|
|
267
454
|
[
|
|
@@ -271,12 +458,13 @@ export abstract class SqlStorageEngine implements StorageEngine {
|
|
|
271
458
|
conv.ownerId,
|
|
272
459
|
conv.title,
|
|
273
460
|
data,
|
|
274
|
-
|
|
461
|
+
conv.messages.length,
|
|
275
462
|
new Date(now).toISOString(),
|
|
276
463
|
new Date(now).toISOString(),
|
|
277
|
-
null,
|
|
464
|
+
conv.parentConversationId ?? null,
|
|
465
|
+
conv.parentMessageId ?? null,
|
|
278
466
|
0,
|
|
279
|
-
|
|
467
|
+
channelMetaJson,
|
|
280
468
|
],
|
|
281
469
|
);
|
|
282
470
|
return conv;
|
|
@@ -297,25 +485,35 @@ export abstract class SqlStorageEngine implements StorageEngine {
|
|
|
297
485
|
const archiveJson = archive ? JSON.stringify(archive) : null;
|
|
298
486
|
const harnessJson = harnessMessages ? JSON.stringify(harnessMessages) : null;
|
|
299
487
|
const continuationJson = continuationMessages ? JSON.stringify(continuationMessages) : null;
|
|
488
|
+
this.egressMeter.trackWrite("update",
|
|
489
|
+
data.length + (archiveJson?.length ?? 0) +
|
|
490
|
+
(harnessJson?.length ?? 0) + (continuationJson?.length ?? 0));
|
|
300
491
|
const msgCount = conversation.messages?.length ?? 0;
|
|
301
492
|
const tid = normalizeTenant(conversation.tenantId);
|
|
302
493
|
const now = new Date(conversation.updatedAt).toISOString();
|
|
303
494
|
const created = new Date(conversation.createdAt).toISOString();
|
|
304
495
|
const parentConvId = conversation.parentConversationId ?? null;
|
|
496
|
+
const parentMsgId = conversation.parentMessageId ?? null;
|
|
305
497
|
const hasPendingApprovals = (conversation.pendingApprovals?.length ?? 0) > 0 ? 1 : 0;
|
|
306
498
|
const channelMetaJson = conversation.channelMeta ? JSON.stringify(conversation.channelMeta) : null;
|
|
307
499
|
await this.executor.run(
|
|
308
500
|
rewrite(
|
|
309
501
|
`INSERT INTO conversations (id, agent_id, tenant_id, owner_id, title, data, message_count, created_at, updated_at,
|
|
310
502
|
tool_result_archive, harness_messages, continuation_messages,
|
|
311
|
-
parent_conversation_id, has_pending_approvals, channel_meta)
|
|
312
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
|
503
|
+
parent_conversation_id, parent_message_id, has_pending_approvals, channel_meta)
|
|
504
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
|
313
505
|
${this.dialect.upsert(["id"])}
|
|
314
506
|
data = excluded.data, title = excluded.title, message_count = excluded.message_count,
|
|
315
507
|
updated_at = excluded.updated_at, tenant_id = excluded.tenant_id, owner_id = excluded.owner_id,
|
|
316
|
-
tool_result_archive
|
|
508
|
+
-- tool_result_archive is append-only from the caller's side; if the
|
|
509
|
+
-- in-memory conversation was loaded via the light get() variant it
|
|
510
|
+
-- won't have _toolResultArchive attached, so we must preserve the
|
|
511
|
+
-- existing column instead of clobbering it with NULL.
|
|
512
|
+
tool_result_archive = COALESCE(excluded.tool_result_archive, conversations.tool_result_archive),
|
|
513
|
+
harness_messages = excluded.harness_messages,
|
|
317
514
|
continuation_messages = excluded.continuation_messages,
|
|
318
515
|
parent_conversation_id = excluded.parent_conversation_id,
|
|
516
|
+
parent_message_id = excluded.parent_message_id,
|
|
319
517
|
has_pending_approvals = excluded.has_pending_approvals,
|
|
320
518
|
channel_meta = excluded.channel_meta`,
|
|
321
519
|
this.dialect,
|
|
@@ -334,6 +532,7 @@ export abstract class SqlStorageEngine implements StorageEngine {
|
|
|
334
532
|
harnessJson,
|
|
335
533
|
continuationJson,
|
|
336
534
|
parentConvId,
|
|
535
|
+
parentMsgId,
|
|
337
536
|
hasPendingApprovals,
|
|
338
537
|
channelMetaJson,
|
|
339
538
|
],
|
|
@@ -374,7 +573,7 @@ export abstract class SqlStorageEngine implements StorageEngine {
|
|
|
374
573
|
// SQLite uses positional ? so we can't reuse $2, need separate params
|
|
375
574
|
const params: unknown[] = [this.agentId, pattern, pattern];
|
|
376
575
|
let sql = `SELECT id, title, updated_at, created_at, owner_id, tenant_id,
|
|
377
|
-
message_count, parent_conversation_id,
|
|
576
|
+
message_count, parent_conversation_id, parent_message_id,
|
|
378
577
|
has_pending_approvals, channel_meta
|
|
379
578
|
FROM conversations
|
|
380
579
|
WHERE agent_id = $1 AND (title LIKE $2 OR data LIKE $3)`;
|
|
@@ -406,6 +605,24 @@ export abstract class SqlStorageEngine implements StorageEngine {
|
|
|
406
605
|
await this.conversations.update(conv);
|
|
407
606
|
return conv;
|
|
408
607
|
},
|
|
608
|
+
|
|
609
|
+
listThreads: async (
|
|
610
|
+
parentConversationId: string,
|
|
611
|
+
): Promise<ConversationSummary[]> => {
|
|
612
|
+
const sql = `SELECT id, title, updated_at, created_at, owner_id, tenant_id,
|
|
613
|
+
message_count, parent_conversation_id, parent_message_id,
|
|
614
|
+
has_pending_approvals, channel_meta
|
|
615
|
+
FROM conversations
|
|
616
|
+
WHERE agent_id = $1
|
|
617
|
+
AND parent_conversation_id = $2
|
|
618
|
+
AND parent_message_id IS NOT NULL
|
|
619
|
+
ORDER BY updated_at DESC`;
|
|
620
|
+
const rows = await this.executor.all(rewrite(sql, this.dialect), [
|
|
621
|
+
this.agentId,
|
|
622
|
+
parentConversationId,
|
|
623
|
+
]);
|
|
624
|
+
return rows.map((r) => this.rowToSummary(r));
|
|
625
|
+
},
|
|
409
626
|
};
|
|
410
627
|
|
|
411
628
|
// -----------------------------------------------------------------------
|
|
@@ -497,21 +714,15 @@ export abstract class SqlStorageEngine implements StorageEngine {
|
|
|
497
714
|
return rows.map((r) => this.rowToReminder(r));
|
|
498
715
|
},
|
|
499
716
|
|
|
500
|
-
create: async (input: {
|
|
501
|
-
task: string;
|
|
502
|
-
scheduledAt: number;
|
|
503
|
-
timezone?: string;
|
|
504
|
-
conversationId: string;
|
|
505
|
-
ownerId?: string;
|
|
506
|
-
tenantId?: string | null;
|
|
507
|
-
}): Promise<Reminder> => {
|
|
717
|
+
create: async (input: ReminderCreateInput): Promise<Reminder> => {
|
|
508
718
|
const id = randomUUID();
|
|
509
719
|
const now = Date.now();
|
|
510
720
|
const tid = normalizeTenant(input.tenantId);
|
|
721
|
+
const recurrenceJson = input.recurrence ? JSON.stringify(input.recurrence) : null;
|
|
511
722
|
await this.executor.run(
|
|
512
723
|
rewrite(
|
|
513
|
-
`INSERT INTO reminders (id, agent_id, tenant_id, owner_id, conversation_id, task, status, scheduled_at, timezone, created_at)
|
|
514
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
|
724
|
+
`INSERT INTO reminders (id, agent_id, tenant_id, owner_id, conversation_id, task, status, scheduled_at, timezone, created_at, recurrence, occurrence_count)
|
|
725
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
|
515
726
|
this.dialect,
|
|
516
727
|
),
|
|
517
728
|
[
|
|
@@ -525,6 +736,8 @@ export abstract class SqlStorageEngine implements StorageEngine {
|
|
|
525
736
|
input.scheduledAt,
|
|
526
737
|
input.timezone ?? null,
|
|
527
738
|
new Date(now).toISOString(),
|
|
739
|
+
recurrenceJson,
|
|
740
|
+
0,
|
|
528
741
|
],
|
|
529
742
|
);
|
|
530
743
|
return {
|
|
@@ -537,9 +750,54 @@ export abstract class SqlStorageEngine implements StorageEngine {
|
|
|
537
750
|
conversationId: input.conversationId,
|
|
538
751
|
ownerId: input.ownerId,
|
|
539
752
|
tenantId: input.tenantId,
|
|
753
|
+
recurrence: input.recurrence ?? null,
|
|
754
|
+
occurrenceCount: 0,
|
|
540
755
|
};
|
|
541
756
|
},
|
|
542
757
|
|
|
758
|
+
update: async (id: string, fields: { scheduledAt?: number; occurrenceCount?: number; status?: ReminderStatus }): Promise<Reminder> => {
|
|
759
|
+
const setClauses: string[] = [];
|
|
760
|
+
const params: unknown[] = [];
|
|
761
|
+
let idx = 1;
|
|
762
|
+
if (fields.scheduledAt !== undefined) {
|
|
763
|
+
setClauses.push(`scheduled_at = $${idx++}`);
|
|
764
|
+
params.push(fields.scheduledAt);
|
|
765
|
+
}
|
|
766
|
+
if (fields.occurrenceCount !== undefined) {
|
|
767
|
+
setClauses.push(`occurrence_count = $${idx++}`);
|
|
768
|
+
params.push(fields.occurrenceCount);
|
|
769
|
+
}
|
|
770
|
+
if (fields.status !== undefined) {
|
|
771
|
+
setClauses.push(`status = $${idx++}`);
|
|
772
|
+
params.push(fields.status);
|
|
773
|
+
}
|
|
774
|
+
if (setClauses.length === 0) {
|
|
775
|
+
// Nothing to update — just fetch
|
|
776
|
+
const row = await this.executor.get(
|
|
777
|
+
rewrite("SELECT * FROM reminders WHERE id = $1 AND agent_id = $2", this.dialect),
|
|
778
|
+
[id, this.agentId],
|
|
779
|
+
);
|
|
780
|
+
if (!row) throw new Error(`Reminder ${id} not found`);
|
|
781
|
+
return this.rowToReminder(row);
|
|
782
|
+
}
|
|
783
|
+
params.push(id, this.agentId);
|
|
784
|
+
const idIdx = idx++;
|
|
785
|
+
const agentIdx = idx++;
|
|
786
|
+
await this.executor.run(
|
|
787
|
+
rewrite(
|
|
788
|
+
`UPDATE reminders SET ${setClauses.join(", ")} WHERE id = $${idIdx} AND agent_id = $${agentIdx}`,
|
|
789
|
+
this.dialect,
|
|
790
|
+
),
|
|
791
|
+
params,
|
|
792
|
+
);
|
|
793
|
+
const row = await this.executor.get(
|
|
794
|
+
rewrite("SELECT * FROM reminders WHERE id = $1 AND agent_id = $2", this.dialect),
|
|
795
|
+
[id, this.agentId],
|
|
796
|
+
);
|
|
797
|
+
if (!row) throw new Error(`Reminder ${id} not found`);
|
|
798
|
+
return this.rowToReminder(row);
|
|
799
|
+
},
|
|
800
|
+
|
|
543
801
|
cancel: async (id: string): Promise<Reminder> => {
|
|
544
802
|
await this.executor.run(
|
|
545
803
|
rewrite(
|
|
@@ -910,6 +1168,7 @@ export abstract class SqlStorageEngine implements StorageEngine {
|
|
|
910
1168
|
messageCount: row.message_count as number,
|
|
911
1169
|
hasPendingApprovals: !!(row.has_pending_approvals),
|
|
912
1170
|
parentConversationId: (row.parent_conversation_id as string) || undefined,
|
|
1171
|
+
parentMessageId: (row.parent_message_id as string) || undefined,
|
|
913
1172
|
channelMeta: rawChannelMeta
|
|
914
1173
|
? (typeof rawChannelMeta === "string" ? JSON.parse(rawChannelMeta) : rawChannelMeta)
|
|
915
1174
|
: undefined,
|
|
@@ -918,6 +1177,16 @@ export abstract class SqlStorageEngine implements StorageEngine {
|
|
|
918
1177
|
|
|
919
1178
|
private rowToReminder(row: QueryRow): Reminder {
|
|
920
1179
|
const tid = row.tenant_id as string;
|
|
1180
|
+
let recurrence: Reminder["recurrence"] = null;
|
|
1181
|
+
if (row.recurrence) {
|
|
1182
|
+
try {
|
|
1183
|
+
recurrence = typeof row.recurrence === "string"
|
|
1184
|
+
? JSON.parse(row.recurrence)
|
|
1185
|
+
: row.recurrence;
|
|
1186
|
+
} catch {
|
|
1187
|
+
recurrence = null;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
921
1190
|
return {
|
|
922
1191
|
id: row.id as string,
|
|
923
1192
|
task: row.task as string,
|
|
@@ -928,6 +1197,8 @@ export abstract class SqlStorageEngine implements StorageEngine {
|
|
|
928
1197
|
conversationId: row.conversation_id as string,
|
|
929
1198
|
ownerId: (row.owner_id as string) ?? undefined,
|
|
930
1199
|
tenantId: tid === DEFAULT_TENANT ? null : tid,
|
|
1200
|
+
recurrence,
|
|
1201
|
+
occurrenceCount: (row.occurrence_count as number) ?? 0,
|
|
931
1202
|
};
|
|
932
1203
|
}
|
|
933
1204
|
|
|
@@ -6,13 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
import type {
|
|
8
8
|
Conversation,
|
|
9
|
+
ConversationCreateInit,
|
|
9
10
|
ConversationStore,
|
|
10
11
|
ConversationSummary,
|
|
11
12
|
PendingSubagentResult,
|
|
12
13
|
} from "../state.js";
|
|
13
14
|
import type { MainMemory, MemoryStore } from "../memory.js";
|
|
14
15
|
import type { TodoItem, TodoStore } from "../todo-tools.js";
|
|
15
|
-
import type { Reminder, ReminderStore } from "../reminder-store.js";
|
|
16
|
+
import type { Reminder, ReminderCreateInput, ReminderStatus, ReminderStore } from "../reminder-store.js";
|
|
16
17
|
import type { StorageEngine } from "./engine.js";
|
|
17
18
|
|
|
18
19
|
// ---------------------------------------------------------------------------
|
|
@@ -35,8 +36,16 @@ export function createConversationStoreFromEngine(
|
|
|
35
36
|
engine.conversations.list(ownerId, tenantId),
|
|
36
37
|
get: (conversationId: string) =>
|
|
37
38
|
engine.conversations.get(conversationId),
|
|
38
|
-
|
|
39
|
-
engine.conversations.
|
|
39
|
+
getWithArchive: (conversationId: string) =>
|
|
40
|
+
engine.conversations.getWithArchive(conversationId),
|
|
41
|
+
getStatusSnapshot: (conversationId: string) =>
|
|
42
|
+
engine.conversations.getStatusSnapshot(conversationId),
|
|
43
|
+
create: (
|
|
44
|
+
ownerId?: string,
|
|
45
|
+
title?: string,
|
|
46
|
+
tenantId?: string | null,
|
|
47
|
+
init?: ConversationCreateInit,
|
|
48
|
+
) => engine.conversations.create(ownerId, title, tenantId, init),
|
|
40
49
|
update: (conversation: Conversation) =>
|
|
41
50
|
engine.conversations.update(conversation),
|
|
42
51
|
rename: (conversationId: string, title: string) =>
|
|
@@ -47,6 +56,8 @@ export function createConversationStoreFromEngine(
|
|
|
47
56
|
engine.conversations.appendSubagentResult(conversationId, result),
|
|
48
57
|
clearCallbackLock: (conversationId: string) =>
|
|
49
58
|
engine.conversations.clearCallbackLock(conversationId),
|
|
59
|
+
listThreads: (parentConversationId: string) =>
|
|
60
|
+
engine.conversations.listThreads(parentConversationId),
|
|
50
61
|
};
|
|
51
62
|
}
|
|
52
63
|
|
|
@@ -86,14 +97,9 @@ export function createReminderStoreFromEngine(
|
|
|
86
97
|
): ReminderStore {
|
|
87
98
|
return {
|
|
88
99
|
list: () => engine.reminders.list(),
|
|
89
|
-
create: (input:
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
timezone?: string;
|
|
93
|
-
conversationId: string;
|
|
94
|
-
ownerId?: string;
|
|
95
|
-
tenantId?: string | null;
|
|
96
|
-
}) => engine.reminders.create(input),
|
|
100
|
+
create: (input: ReminderCreateInput) => engine.reminders.create(input),
|
|
101
|
+
update: (id: string, fields: { scheduledAt?: number; occurrenceCount?: number; status?: ReminderStatus }) =>
|
|
102
|
+
engine.reminders.update(id, fields),
|
|
97
103
|
cancel: (id: string) => engine.reminders.cancel(id),
|
|
98
104
|
delete: (id: string) => engine.reminders.delete(id),
|
|
99
105
|
};
|
package/src/telemetry.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import type { AgentEvent } from "@poncho-ai/sdk";
|
|
2
|
+
import { createLogger } from "@poncho-ai/sdk";
|
|
3
|
+
|
|
4
|
+
const eventLog = createLogger("event");
|
|
5
|
+
const telemetryLog = createLogger("telemetry");
|
|
2
6
|
|
|
3
7
|
const MAX_FIELD_LENGTH = 200;
|
|
4
8
|
const OMIT_FROM_LOG = new Set(["continuationMessages", "_harnessMessages", "messages", "compactedHistory"]);
|
|
@@ -70,12 +74,13 @@ export class TelemetryEmitter {
|
|
|
70
74
|
await this.sendOtlp(event, otlp);
|
|
71
75
|
}
|
|
72
76
|
// Default behavior in local dev: print concise structured logs.
|
|
73
|
-
// Skip per-token stream logs
|
|
74
|
-
|
|
77
|
+
// Skip per-token stream logs and browser screencast frames — both are
|
|
78
|
+
// high-volume and would drown the log.
|
|
79
|
+
if (event.type === "model:chunk" || event.type === "browser:frame") {
|
|
75
80
|
return;
|
|
76
81
|
}
|
|
77
82
|
// Strip large binary payloads (e.g. base64 images) to keep logs readable.
|
|
78
|
-
|
|
83
|
+
eventLog.debug(`${event.type} ${sanitizeEventForLog(event)}`);
|
|
79
84
|
}
|
|
80
85
|
|
|
81
86
|
private async sendOtlp(event: AgentEvent, otlp: OtlpConfig): Promise<void> {
|
|
@@ -108,10 +113,8 @@ export class TelemetryEmitter {
|
|
|
108
113
|
}),
|
|
109
114
|
});
|
|
110
115
|
} catch (err) {
|
|
111
|
-
|
|
112
|
-
`
|
|
113
|
-
err instanceof Error ? err.message : String(err)
|
|
114
|
-
}`,
|
|
116
|
+
telemetryLog.warn(
|
|
117
|
+
`OTLP log delivery failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
115
118
|
);
|
|
116
119
|
}
|
|
117
120
|
}
|
package/src/upload-store.ts
CHANGED
|
@@ -3,8 +3,11 @@ import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
|
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { resolve } from "node:path";
|
|
6
|
+
import { createLogger } from "@poncho-ai/sdk";
|
|
6
7
|
import type { UploadsConfig } from "./config.js";
|
|
7
8
|
|
|
9
|
+
const uploadLog = createLogger("upload");
|
|
10
|
+
|
|
8
11
|
/**
|
|
9
12
|
* Try to dynamically import a module, first from the harness's own
|
|
10
13
|
* node_modules, then from the user's project directory. This handles
|
|
@@ -62,7 +65,7 @@ class CachedUploadStore implements UploadStore {
|
|
|
62
65
|
|
|
63
66
|
// Fire off the real upload in the background — don't block the caller.
|
|
64
67
|
this.inner.put(key, data, mediaType).catch((err) => {
|
|
65
|
-
|
|
68
|
+
uploadLog.error(`background upload failed: ${err instanceof Error ? err.message : err}`);
|
|
66
69
|
});
|
|
67
70
|
|
|
68
71
|
return ref;
|
|
@@ -343,7 +346,7 @@ export class S3UploadStore implements UploadStore {
|
|
|
343
346
|
// ---------------------------------------------------------------------------
|
|
344
347
|
|
|
345
348
|
const warn = (msg: string) => {
|
|
346
|
-
|
|
349
|
+
uploadLog.warn(msg);
|
|
347
350
|
};
|
|
348
351
|
|
|
349
352
|
export const createUploadStore = async (
|
|
@@ -363,7 +366,7 @@ export const createUploadStore = async (
|
|
|
363
366
|
const store = new VercelBlobUploadStore(workingDir, config?.access ?? "public");
|
|
364
367
|
try {
|
|
365
368
|
await store.loadSdk();
|
|
366
|
-
|
|
369
|
+
uploadLog.item("using vercel-blob store");
|
|
367
370
|
return new CachedUploadStore(store);
|
|
368
371
|
} catch {
|
|
369
372
|
warn(
|
|
@@ -393,7 +396,7 @@ export const createUploadStore = async (
|
|
|
393
396
|
);
|
|
394
397
|
try {
|
|
395
398
|
await store.ensureClient();
|
|
396
|
-
|
|
399
|
+
uploadLog.item(`using s3 store (bucket: ${bucket})`);
|
|
397
400
|
return new CachedUploadStore(store);
|
|
398
401
|
} catch {
|
|
399
402
|
warn(
|