@poncho-ai/harness 0.40.1 → 0.42.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 +137 -0
- package/dist/index.d.ts +92 -5
- package/dist/index.js +441 -36
- package/package.json +1 -1
- package/src/harness.ts +88 -18
- package/src/mcp.ts +102 -23
- package/src/orchestrator/index.ts +6 -0
- package/src/orchestrator/run-conversation-turn.ts +420 -0
- package/src/storage/engine.ts +2 -0
- package/src/storage/memory-engine.ts +1 -1
- package/src/storage/sql-dialect.ts +1 -1
- package/test/harness-config-injection.test.ts +63 -0
- package/test/harness-injection.test.ts +93 -0
- package/test/mcp-tenant-cache.test.ts +311 -0
- package/test/mcp.test.ts +174 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// runConversationTurn — load-bearing helper that runs a single primary chat
|
|
3
|
+
// turn end-to-end against a ConversationStore: loads the conversation,
|
|
4
|
+
// persists the user message before the run, drives the model + tool loop
|
|
5
|
+
// via executeConversationTurn, periodically persists the in-flight assistant
|
|
6
|
+
// draft, handles approval checkpoints + continuations + cancellation, and
|
|
7
|
+
// finalises the conversation row on completion.
|
|
8
|
+
//
|
|
9
|
+
// This was extracted from packages/cli/src/index.ts (POST
|
|
10
|
+
// /api/conversations/:id/messages handler) so consumers other than the CLI
|
|
11
|
+
// (PonchOS, custom servers) can ship the *same* conversation lifecycle
|
|
12
|
+
// without copy-pasting hundreds of lines.
|
|
13
|
+
//
|
|
14
|
+
// Caller responsibilities (NOT done here):
|
|
15
|
+
// - auth / ownership checks
|
|
16
|
+
// - active-run dedup (one run at a time per conversation)
|
|
17
|
+
// - streaming events to a real client (use opts.onEvent)
|
|
18
|
+
// - triggering continuation runs after this returns continuation: true
|
|
19
|
+
// - conversation title inference (helper preserves existing title)
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
import { randomUUID } from "node:crypto";
|
|
23
|
+
import type { AgentEvent, FileInput, Message } from "@poncho-ai/sdk";
|
|
24
|
+
import { createLogger } from "@poncho-ai/sdk";
|
|
25
|
+
import type { AgentHarness } from "../harness.js";
|
|
26
|
+
import type { ConversationStore } from "../state.js";
|
|
27
|
+
import { deriveUploadKey } from "../upload-store.js";
|
|
28
|
+
import { withToolResultArchiveParam } from "./continuation.js";
|
|
29
|
+
import { resolveRunRequest } from "./history.js";
|
|
30
|
+
import {
|
|
31
|
+
applyTurnMetadata,
|
|
32
|
+
buildApprovalCheckpoints,
|
|
33
|
+
buildAssistantMetadata,
|
|
34
|
+
cloneSections,
|
|
35
|
+
createTurnDraftState,
|
|
36
|
+
executeConversationTurn,
|
|
37
|
+
flushTurnDraft,
|
|
38
|
+
} from "./turn.js";
|
|
39
|
+
|
|
40
|
+
const log = createLogger("orchestrator");
|
|
41
|
+
|
|
42
|
+
export interface RunConversationTurnOpts {
|
|
43
|
+
/** Initialised harness instance. */
|
|
44
|
+
harness: AgentHarness;
|
|
45
|
+
/** Conversation store backing the turn (typically `engine.conversations` from a StorageEngine). */
|
|
46
|
+
conversationStore: ConversationStore;
|
|
47
|
+
conversationId: string;
|
|
48
|
+
/** The user's new message text. Required (use `""` if you only want to attach files). */
|
|
49
|
+
task: string;
|
|
50
|
+
/**
|
|
51
|
+
* Optional file attachments (FileInput.data is base64 / data URI / https URL).
|
|
52
|
+
* Files are uploaded via `harness.uploadStore` first so the persisted user
|
|
53
|
+
* message references stable URLs instead of fat base64 blobs.
|
|
54
|
+
*/
|
|
55
|
+
files?: FileInput[];
|
|
56
|
+
/**
|
|
57
|
+
* Extra parameters merged into runInput.parameters. Use this for recall
|
|
58
|
+
* corpus, archive lookup keys, messaging metadata, etc. Do NOT include
|
|
59
|
+
* `__activeConversationId`, `__ownerId`, or the tool-result-archive — the
|
|
60
|
+
* helper sets those itself.
|
|
61
|
+
*/
|
|
62
|
+
parameters?: Record<string, unknown>;
|
|
63
|
+
abortSignal?: AbortSignal;
|
|
64
|
+
tenantId?: string | null;
|
|
65
|
+
/** Per-event hook — called for every AgentEvent yielded by the run, in order. */
|
|
66
|
+
onEvent?: (event: AgentEvent) => void | Promise<void>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface RunConversationTurnResult {
|
|
70
|
+
/** runId of the most recent run started during this turn. */
|
|
71
|
+
latestRunId: string;
|
|
72
|
+
/** True if the run was cancelled (via abortSignal or run:cancelled event). */
|
|
73
|
+
cancelled: boolean;
|
|
74
|
+
/** True if the run errored. The error has been emitted via onEvent as run:error. */
|
|
75
|
+
errored: boolean;
|
|
76
|
+
/** True if the run requested a continuation. Caller is responsible for triggering the continuation. */
|
|
77
|
+
continuation: boolean;
|
|
78
|
+
/** True if the run paused at a tool-approval checkpoint. */
|
|
79
|
+
checkpointed: boolean;
|
|
80
|
+
contextTokens: number;
|
|
81
|
+
contextWindow: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const runConversationTurn = async (
|
|
85
|
+
opts: RunConversationTurnOpts,
|
|
86
|
+
): Promise<RunConversationTurnResult> => {
|
|
87
|
+
const conversation = await opts.conversationStore.getWithArchive(opts.conversationId);
|
|
88
|
+
if (!conversation) {
|
|
89
|
+
throw new Error(`Conversation not found: ${opts.conversationId}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const canonicalHistory = resolveRunRequest(conversation, {
|
|
93
|
+
conversationId: opts.conversationId,
|
|
94
|
+
messages: conversation.messages,
|
|
95
|
+
});
|
|
96
|
+
const shouldRebuildCanonical = canonicalHistory.shouldRebuildCanonical;
|
|
97
|
+
const harnessMessages = [...canonicalHistory.messages];
|
|
98
|
+
const historyMessages = [...conversation.messages];
|
|
99
|
+
const preRunMessages = [...conversation.messages];
|
|
100
|
+
|
|
101
|
+
// Build user content — upload any files first so the persisted message
|
|
102
|
+
// carries stable refs instead of fat base64 blobs.
|
|
103
|
+
let userContent: Message["content"] = opts.task;
|
|
104
|
+
if (opts.files && opts.files.length > 0 && opts.harness.uploadStore) {
|
|
105
|
+
const uploadedParts = await Promise.all(
|
|
106
|
+
opts.files.map(async (f) => {
|
|
107
|
+
const buf = Buffer.from(f.data, "base64");
|
|
108
|
+
const key = deriveUploadKey(buf, f.mediaType);
|
|
109
|
+
const ref = await opts.harness.uploadStore!.put(key, buf, f.mediaType);
|
|
110
|
+
return {
|
|
111
|
+
type: "file" as const,
|
|
112
|
+
data: ref,
|
|
113
|
+
mediaType: f.mediaType,
|
|
114
|
+
filename: f.filename,
|
|
115
|
+
};
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
userContent = [
|
|
119
|
+
{ type: "text" as const, text: opts.task },
|
|
120
|
+
...uploadedParts,
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const turnTimestamp = Date.now();
|
|
125
|
+
const userMessage: Message = {
|
|
126
|
+
role: "user",
|
|
127
|
+
content: userContent,
|
|
128
|
+
metadata: { id: randomUUID(), timestamp: turnTimestamp },
|
|
129
|
+
};
|
|
130
|
+
const assistantId = randomUUID();
|
|
131
|
+
const draft = createTurnDraftState();
|
|
132
|
+
|
|
133
|
+
let latestRunId = conversation.runtimeRunId ?? "";
|
|
134
|
+
let runCancelled = false;
|
|
135
|
+
let runContinuationMessages: Message[] | undefined;
|
|
136
|
+
let cancelHarnessMessages: Message[] | undefined;
|
|
137
|
+
let checkpointedRun = false;
|
|
138
|
+
|
|
139
|
+
const buildMessages = (): Message[] => {
|
|
140
|
+
const draftSections = cloneSections(draft.sections);
|
|
141
|
+
if (draft.currentTools.length > 0) {
|
|
142
|
+
draftSections.push({ type: "tools", content: [...draft.currentTools] });
|
|
143
|
+
}
|
|
144
|
+
if (draft.currentText.length > 0) {
|
|
145
|
+
draftSections.push({ type: "text", content: draft.currentText });
|
|
146
|
+
}
|
|
147
|
+
const userTurn: Message[] = [userMessage];
|
|
148
|
+
const hasDraftContent =
|
|
149
|
+
draft.assistantResponse.length > 0 ||
|
|
150
|
+
draft.toolTimeline.length > 0 ||
|
|
151
|
+
draftSections.length > 0;
|
|
152
|
+
if (!hasDraftContent) {
|
|
153
|
+
return [...historyMessages, ...userTurn];
|
|
154
|
+
}
|
|
155
|
+
return [
|
|
156
|
+
...historyMessages,
|
|
157
|
+
...userTurn,
|
|
158
|
+
{
|
|
159
|
+
role: "assistant" as const,
|
|
160
|
+
content: draft.assistantResponse,
|
|
161
|
+
metadata: buildAssistantMetadata(draft, draftSections, {
|
|
162
|
+
id: assistantId,
|
|
163
|
+
timestamp: turnTimestamp,
|
|
164
|
+
}),
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const persistDraft = async (): Promise<void> => {
|
|
170
|
+
if (draft.assistantResponse.length === 0 && draft.toolTimeline.length === 0) return;
|
|
171
|
+
conversation.messages = buildMessages();
|
|
172
|
+
conversation.updatedAt = Date.now();
|
|
173
|
+
await opts.conversationStore.update(conversation);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Persist the user turn immediately so a crash mid-run still records what
|
|
177
|
+
// the user said. Fire-and-forget — don't block the run.
|
|
178
|
+
conversation.messages = [...historyMessages, userMessage];
|
|
179
|
+
conversation.subagentCallbackCount = 0;
|
|
180
|
+
conversation._continuationCount = undefined;
|
|
181
|
+
conversation.updatedAt = Date.now();
|
|
182
|
+
opts.conversationStore.update(conversation).catch((err) => {
|
|
183
|
+
log.error(
|
|
184
|
+
`failed to persist user turn: ${err instanceof Error ? err.message : String(err)}`,
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const execution = await executeConversationTurn({
|
|
190
|
+
harness: opts.harness,
|
|
191
|
+
runInput: {
|
|
192
|
+
task: opts.task,
|
|
193
|
+
conversationId: opts.conversationId,
|
|
194
|
+
tenantId: opts.tenantId ?? undefined,
|
|
195
|
+
parameters: withToolResultArchiveParam(
|
|
196
|
+
{
|
|
197
|
+
...(opts.parameters ?? {}),
|
|
198
|
+
__activeConversationId: opts.conversationId,
|
|
199
|
+
__ownerId: conversation.ownerId,
|
|
200
|
+
},
|
|
201
|
+
conversation,
|
|
202
|
+
),
|
|
203
|
+
messages: harnessMessages,
|
|
204
|
+
files: opts.files && opts.files.length > 0 ? opts.files : undefined,
|
|
205
|
+
abortSignal: opts.abortSignal,
|
|
206
|
+
},
|
|
207
|
+
initialContextTokens: conversation.contextTokens ?? 0,
|
|
208
|
+
initialContextWindow: conversation.contextWindow ?? 0,
|
|
209
|
+
onEvent: async (event, eventDraft) => {
|
|
210
|
+
// Sync our outer draft from the executor's so persistDraft sees the latest state.
|
|
211
|
+
draft.assistantResponse = eventDraft.assistantResponse;
|
|
212
|
+
draft.toolTimeline = eventDraft.toolTimeline;
|
|
213
|
+
draft.sections = eventDraft.sections;
|
|
214
|
+
draft.currentTools = eventDraft.currentTools;
|
|
215
|
+
draft.currentText = eventDraft.currentText;
|
|
216
|
+
|
|
217
|
+
if (event.type === "run:started") {
|
|
218
|
+
latestRunId = event.runId;
|
|
219
|
+
}
|
|
220
|
+
if (event.type === "run:cancelled") {
|
|
221
|
+
runCancelled = true;
|
|
222
|
+
if (event.messages) cancelHarnessMessages = event.messages;
|
|
223
|
+
}
|
|
224
|
+
if (event.type === "compaction:completed") {
|
|
225
|
+
if (event.compactedMessages) {
|
|
226
|
+
historyMessages.length = 0;
|
|
227
|
+
historyMessages.push(...event.compactedMessages);
|
|
228
|
+
const preservedFromHistory = historyMessages.length - 1;
|
|
229
|
+
const removedCount =
|
|
230
|
+
preRunMessages.length - Math.max(0, preservedFromHistory);
|
|
231
|
+
const existingHistory = conversation.compactedHistory ?? [];
|
|
232
|
+
conversation.compactedHistory = [
|
|
233
|
+
...existingHistory,
|
|
234
|
+
...preRunMessages.slice(0, removedCount),
|
|
235
|
+
];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (event.type === "step:completed") {
|
|
239
|
+
await persistDraft();
|
|
240
|
+
}
|
|
241
|
+
if (event.type === "tool:approval:required") {
|
|
242
|
+
const toolText = `- approval required \`${event.tool}\``;
|
|
243
|
+
draft.toolTimeline.push(toolText);
|
|
244
|
+
draft.currentTools.push(toolText);
|
|
245
|
+
const existing = Array.isArray(conversation.pendingApprovals)
|
|
246
|
+
? conversation.pendingApprovals
|
|
247
|
+
: [];
|
|
248
|
+
if (!existing.some((a) => a.approvalId === event.approvalId)) {
|
|
249
|
+
conversation.pendingApprovals = [
|
|
250
|
+
...existing,
|
|
251
|
+
{
|
|
252
|
+
approvalId: event.approvalId,
|
|
253
|
+
runId: latestRunId || conversation.runtimeRunId || "",
|
|
254
|
+
tool: event.tool,
|
|
255
|
+
toolCallId: undefined,
|
|
256
|
+
input: (event.input ?? {}) as Record<string, unknown>,
|
|
257
|
+
checkpointMessages: undefined,
|
|
258
|
+
baseMessageCount: historyMessages.length,
|
|
259
|
+
pendingToolCalls: [],
|
|
260
|
+
},
|
|
261
|
+
];
|
|
262
|
+
conversation.updatedAt = Date.now();
|
|
263
|
+
await opts.conversationStore.update(conversation);
|
|
264
|
+
}
|
|
265
|
+
await persistDraft();
|
|
266
|
+
}
|
|
267
|
+
if (event.type === "tool:approval:checkpoint") {
|
|
268
|
+
conversation.messages = buildMessages();
|
|
269
|
+
conversation.pendingApprovals = buildApprovalCheckpoints({
|
|
270
|
+
approvals: event.approvals,
|
|
271
|
+
runId: latestRunId,
|
|
272
|
+
checkpointMessages: event.checkpointMessages,
|
|
273
|
+
baseMessageCount: historyMessages.length,
|
|
274
|
+
pendingToolCalls: event.pendingToolCalls,
|
|
275
|
+
});
|
|
276
|
+
conversation._toolResultArchive = opts.harness.getToolResultArchive(
|
|
277
|
+
opts.conversationId,
|
|
278
|
+
);
|
|
279
|
+
conversation.updatedAt = Date.now();
|
|
280
|
+
await opts.conversationStore.update(conversation);
|
|
281
|
+
checkpointedRun = true;
|
|
282
|
+
}
|
|
283
|
+
if (event.type === "run:completed") {
|
|
284
|
+
if (event.result.continuation && event.result.continuationMessages) {
|
|
285
|
+
runContinuationMessages = event.result.continuationMessages;
|
|
286
|
+
conversation.messages = buildMessages();
|
|
287
|
+
conversation._continuationMessages = runContinuationMessages;
|
|
288
|
+
conversation._harnessMessages = runContinuationMessages;
|
|
289
|
+
conversation._toolResultArchive = opts.harness.getToolResultArchive(
|
|
290
|
+
opts.conversationId,
|
|
291
|
+
);
|
|
292
|
+
conversation.runtimeRunId = latestRunId || conversation.runtimeRunId;
|
|
293
|
+
if (!checkpointedRun) {
|
|
294
|
+
conversation.pendingApprovals = [];
|
|
295
|
+
}
|
|
296
|
+
if ((event.result.contextTokens ?? 0) > 0) {
|
|
297
|
+
conversation.contextTokens = event.result.contextTokens!;
|
|
298
|
+
}
|
|
299
|
+
if ((event.result.contextWindow ?? 0) > 0) {
|
|
300
|
+
conversation.contextWindow = event.result.contextWindow!;
|
|
301
|
+
}
|
|
302
|
+
conversation.updatedAt = Date.now();
|
|
303
|
+
await opts.conversationStore.update(conversation);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (opts.onEvent) {
|
|
308
|
+
await opts.onEvent(event);
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
flushTurnDraft(draft);
|
|
314
|
+
latestRunId = execution.latestRunId || latestRunId;
|
|
315
|
+
|
|
316
|
+
if (!checkpointedRun && !runContinuationMessages) {
|
|
317
|
+
conversation.messages = buildMessages();
|
|
318
|
+
applyTurnMetadata(
|
|
319
|
+
conversation,
|
|
320
|
+
{
|
|
321
|
+
latestRunId,
|
|
322
|
+
contextTokens: execution.runContextTokens,
|
|
323
|
+
contextWindow: execution.runContextWindow,
|
|
324
|
+
harnessMessages: execution.runHarnessMessages,
|
|
325
|
+
toolResultArchive: opts.harness.getToolResultArchive(opts.conversationId),
|
|
326
|
+
},
|
|
327
|
+
{ shouldRebuildCanonical },
|
|
328
|
+
);
|
|
329
|
+
await opts.conversationStore.update(conversation);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
latestRunId,
|
|
334
|
+
cancelled: runCancelled,
|
|
335
|
+
errored: false,
|
|
336
|
+
continuation: !!runContinuationMessages,
|
|
337
|
+
checkpointed: checkpointedRun,
|
|
338
|
+
contextTokens: execution.runContextTokens,
|
|
339
|
+
contextWindow: execution.runContextWindow,
|
|
340
|
+
};
|
|
341
|
+
} catch (error) {
|
|
342
|
+
flushTurnDraft(draft);
|
|
343
|
+
const aborted = opts.abortSignal?.aborted === true;
|
|
344
|
+
if (aborted || runCancelled) {
|
|
345
|
+
if (
|
|
346
|
+
draft.assistantResponse.length > 0 ||
|
|
347
|
+
draft.toolTimeline.length > 0 ||
|
|
348
|
+
draft.sections.length > 0
|
|
349
|
+
) {
|
|
350
|
+
conversation.messages = buildMessages();
|
|
351
|
+
applyTurnMetadata(
|
|
352
|
+
conversation,
|
|
353
|
+
{
|
|
354
|
+
latestRunId,
|
|
355
|
+
contextTokens: 0,
|
|
356
|
+
contextWindow: 0,
|
|
357
|
+
harnessMessages: cancelHarnessMessages,
|
|
358
|
+
toolResultArchive: opts.harness.getToolResultArchive(opts.conversationId),
|
|
359
|
+
},
|
|
360
|
+
{ shouldRebuildCanonical: true },
|
|
361
|
+
);
|
|
362
|
+
await opts.conversationStore.update(conversation);
|
|
363
|
+
}
|
|
364
|
+
if (!checkpointedRun) {
|
|
365
|
+
// Clear any pending approvals — the run was cancelled, they're stale.
|
|
366
|
+
const fresh = await opts.conversationStore.get(opts.conversationId);
|
|
367
|
+
if (fresh && Array.isArray(fresh.pendingApprovals) && fresh.pendingApprovals.length > 0) {
|
|
368
|
+
fresh.pendingApprovals = [];
|
|
369
|
+
await opts.conversationStore.update(fresh);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
latestRunId,
|
|
374
|
+
cancelled: true,
|
|
375
|
+
errored: false,
|
|
376
|
+
continuation: false,
|
|
377
|
+
checkpointed: checkpointedRun,
|
|
378
|
+
contextTokens: 0,
|
|
379
|
+
contextWindow: 0,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Real error: emit run:error, persist whatever we have.
|
|
384
|
+
const errorEvent: AgentEvent = {
|
|
385
|
+
type: "run:error",
|
|
386
|
+
runId: latestRunId || "run_unknown",
|
|
387
|
+
error: {
|
|
388
|
+
code: "RUN_ERROR",
|
|
389
|
+
message: error instanceof Error ? error.message : "Unknown error",
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
if (opts.onEvent) {
|
|
393
|
+
try {
|
|
394
|
+
await opts.onEvent(errorEvent);
|
|
395
|
+
} catch (hookErr) {
|
|
396
|
+
log.error(
|
|
397
|
+
`onEvent threw on run:error: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (
|
|
402
|
+
draft.assistantResponse.length > 0 ||
|
|
403
|
+
draft.toolTimeline.length > 0 ||
|
|
404
|
+
draft.sections.length > 0
|
|
405
|
+
) {
|
|
406
|
+
conversation.messages = buildMessages();
|
|
407
|
+
conversation.updatedAt = Date.now();
|
|
408
|
+
await opts.conversationStore.update(conversation);
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
latestRunId,
|
|
412
|
+
cancelled: false,
|
|
413
|
+
errored: true,
|
|
414
|
+
continuation: false,
|
|
415
|
+
checkpointed: checkpointedRun,
|
|
416
|
+
contextTokens: 0,
|
|
417
|
+
contextWindow: 0,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
};
|
package/src/storage/engine.ts
CHANGED
|
@@ -33,6 +33,8 @@ export interface VfsDirEntry {
|
|
|
33
33
|
// ---------------------------------------------------------------------------
|
|
34
34
|
|
|
35
35
|
export interface StorageEngine {
|
|
36
|
+
/** Partition key: every read/write is scoped to this agent id. */
|
|
37
|
+
readonly agentId: string;
|
|
36
38
|
/** Run migrations and prepare the storage backend. */
|
|
37
39
|
initialize(): Promise<void>;
|
|
38
40
|
/** Gracefully release resources. */
|
|
@@ -55,7 +55,7 @@ const vfsKey = (tenantId: string, path: string) => `${tenantId}\0${path}`;
|
|
|
55
55
|
// ---------------------------------------------------------------------------
|
|
56
56
|
|
|
57
57
|
export class InMemoryEngine implements StorageEngine {
|
|
58
|
-
|
|
58
|
+
readonly agentId: string;
|
|
59
59
|
|
|
60
60
|
// Conversation data
|
|
61
61
|
private convs = new Map<string, Conversation>();
|
|
@@ -191,7 +191,7 @@ const colBytes = (v: unknown): number => {
|
|
|
191
191
|
|
|
192
192
|
export abstract class SqlStorageEngine implements StorageEngine {
|
|
193
193
|
protected readonly dialect: Dialect;
|
|
194
|
-
|
|
194
|
+
readonly agentId: string;
|
|
195
195
|
protected abstract readonly executor: QueryExecutor;
|
|
196
196
|
protected readonly egressMeter = new ConversationEgressMeter();
|
|
197
197
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { AgentHarness } from "../src/harness.js";
|
|
6
|
+
import type { PonchoConfig } from "../src/config.js";
|
|
7
|
+
|
|
8
|
+
const AGENT_MD = `---
|
|
9
|
+
name: config-inject-agent
|
|
10
|
+
model:
|
|
11
|
+
provider: anthropic
|
|
12
|
+
name: claude-opus-4-5
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# Config injection test
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
describe("HarnessOptions.config injection (PR 2)", () => {
|
|
19
|
+
it("uses an injected PonchoConfig instead of reading poncho.config.js from disk", async () => {
|
|
20
|
+
const dir = await mkdtemp(join(tmpdir(), "poncho-cfg-injected-"));
|
|
21
|
+
try {
|
|
22
|
+
await writeFile(join(dir, "AGENT.md"), AGENT_MD, "utf8");
|
|
23
|
+
// Deliberately do NOT write a poncho.config.js — the injected
|
|
24
|
+
// config should be used end-to-end.
|
|
25
|
+
const config: PonchoConfig = {
|
|
26
|
+
tools: { web_search: false },
|
|
27
|
+
storage: { provider: "memory" },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const harness = new AgentHarness({ workingDir: dir, config });
|
|
31
|
+
await harness.initialize();
|
|
32
|
+
|
|
33
|
+
const names = harness.listTools().map((t) => t.name);
|
|
34
|
+
// web_search was disabled in the injected config; bash is a default
|
|
35
|
+
// built-in that should still be registered.
|
|
36
|
+
expect(names).not.toContain("web_search");
|
|
37
|
+
expect(names).toContain("bash");
|
|
38
|
+
} finally {
|
|
39
|
+
await rm(dir, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("disk-loaded behaviour is unchanged when no config option is provided", async () => {
|
|
44
|
+
const dir = await mkdtemp(join(tmpdir(), "poncho-cfg-disk-"));
|
|
45
|
+
try {
|
|
46
|
+
await writeFile(join(dir, "AGENT.md"), AGENT_MD, "utf8");
|
|
47
|
+
// Write a poncho.config.js that disables a tool — proves loadPonchoConfig
|
|
48
|
+
// ran (otherwise web_search would be present).
|
|
49
|
+
await writeFile(
|
|
50
|
+
join(dir, "poncho.config.js"),
|
|
51
|
+
"export default { tools: { web_search: false }, storage: { provider: 'memory' } };\n",
|
|
52
|
+
"utf8",
|
|
53
|
+
);
|
|
54
|
+
const harness = new AgentHarness({ workingDir: dir });
|
|
55
|
+
await harness.initialize();
|
|
56
|
+
const names = harness.listTools().map((t) => t.name);
|
|
57
|
+
expect(names).not.toContain("web_search");
|
|
58
|
+
expect(names).toContain("bash");
|
|
59
|
+
} finally {
|
|
60
|
+
await rm(dir, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { AgentHarness } from "../src/harness.js";
|
|
6
|
+
import { InMemoryEngine } from "../src/storage/memory-engine.js";
|
|
7
|
+
|
|
8
|
+
const AGENT_MD = `---
|
|
9
|
+
name: injected-agent
|
|
10
|
+
model:
|
|
11
|
+
provider: anthropic
|
|
12
|
+
name: claude-opus-4-5
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# Injected agent
|
|
16
|
+
|
|
17
|
+
You are a test agent.
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
describe("HarnessOptions injection (PR 1)", () => {
|
|
21
|
+
it("initializes without an AGENT.md on disk when agentDefinition + storageEngine are provided", async () => {
|
|
22
|
+
const dir = await mkdtemp(join(tmpdir(), "poncho-injection-"));
|
|
23
|
+
try {
|
|
24
|
+
const engine = new InMemoryEngine("user-123");
|
|
25
|
+
const harness = new AgentHarness({
|
|
26
|
+
workingDir: dir,
|
|
27
|
+
agentDefinition: AGENT_MD,
|
|
28
|
+
storageEngine: engine,
|
|
29
|
+
});
|
|
30
|
+
await expect(harness.initialize()).resolves.toBeUndefined();
|
|
31
|
+
// No AGENT.md was written into `dir` — confirm initialize ran from
|
|
32
|
+
// injected content alone.
|
|
33
|
+
expect(harness.frontmatter?.name).toBe("injected-agent");
|
|
34
|
+
} finally {
|
|
35
|
+
await rm(dir, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("mirrors storageEngine.agentId onto frontmatter.id on the injected path", async () => {
|
|
40
|
+
const dir = await mkdtemp(join(tmpdir(), "poncho-injection-id-"));
|
|
41
|
+
try {
|
|
42
|
+
const engine = new InMemoryEngine("user-456");
|
|
43
|
+
const harness = new AgentHarness({
|
|
44
|
+
workingDir: dir,
|
|
45
|
+
agentDefinition: AGENT_MD,
|
|
46
|
+
storageEngine: engine,
|
|
47
|
+
});
|
|
48
|
+
await harness.initialize();
|
|
49
|
+
expect(harness.frontmatter?.id).toBe("user-456");
|
|
50
|
+
} finally {
|
|
51
|
+
await rm(dir, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("accepts a pre-parsed ParsedAgent as agentDefinition", async () => {
|
|
56
|
+
const dir = await mkdtemp(join(tmpdir(), "poncho-injection-parsed-"));
|
|
57
|
+
try {
|
|
58
|
+
const engine = new InMemoryEngine("user-789");
|
|
59
|
+
const parsed = {
|
|
60
|
+
frontmatter: {
|
|
61
|
+
name: "preparsed-agent",
|
|
62
|
+
model: { provider: "anthropic" as const, name: "claude-opus-4-5" },
|
|
63
|
+
},
|
|
64
|
+
body: "# Pre-parsed agent\n",
|
|
65
|
+
};
|
|
66
|
+
const harness = new AgentHarness({
|
|
67
|
+
workingDir: dir,
|
|
68
|
+
agentDefinition: parsed,
|
|
69
|
+
storageEngine: engine,
|
|
70
|
+
});
|
|
71
|
+
await harness.initialize();
|
|
72
|
+
expect(harness.frontmatter?.name).toBe("preparsed-agent");
|
|
73
|
+
expect(harness.frontmatter?.id).toBe("user-789");
|
|
74
|
+
} finally {
|
|
75
|
+
await rm(dir, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("throws when agentDefinition is provided without storageEngine", () => {
|
|
80
|
+
expect(
|
|
81
|
+
() =>
|
|
82
|
+
new AgentHarness({
|
|
83
|
+
agentDefinition: AGENT_MD,
|
|
84
|
+
}),
|
|
85
|
+
).toThrow(/agentDefinition requires HarnessOptions\.storageEngine/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("falls back to disk path when neither agentDefinition nor storageEngine is provided (existing behaviour unchanged)", async () => {
|
|
89
|
+
// This is implicitly covered by every other test in harness.test.ts —
|
|
90
|
+
// we simply assert that the constructor accepts no injection options.
|
|
91
|
+
expect(() => new AgentHarness({ workingDir: tmpdir() })).not.toThrow();
|
|
92
|
+
});
|
|
93
|
+
});
|