@plaited/acp-harness 0.3.2 → 0.4.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/README.md +53 -31
- package/bin/cli.ts +15 -0
- package/package.json +5 -7
- package/src/acp-client.ts +7 -4
- package/src/adapter-check.ts +0 -1
- package/src/adapter-scaffold.ts +16 -15
- package/src/calibrate.ts +28 -8
- package/src/capture.ts +114 -33
- package/src/grader-loader.ts +3 -3
- package/src/harness.ts +4 -0
- package/src/headless-cli.ts +433 -0
- package/src/headless-history-builder.ts +141 -0
- package/src/headless-output-parser.ts +251 -0
- package/src/headless-session-manager.ts +389 -0
- package/src/headless.schemas.ts +241 -0
- package/src/headless.ts +71 -0
- package/src/headless.types.ts +19 -0
- package/src/integration_tests/acp-claude.spec.ts +170 -0
- package/src/integration_tests/acp-gemini.spec.ts +174 -0
- package/src/schemas.ts +88 -36
- package/src/summarize.ts +4 -8
- package/src/tests/acp-client.spec.ts +1 -1
- package/src/tests/capture-cli.spec.ts +188 -0
- package/src/tests/capture-helpers.spec.ts +229 -67
- package/src/tests/constants.spec.ts +121 -0
- package/src/tests/fixtures/grader-exec.py +3 -3
- package/src/tests/fixtures/grader-module.ts +2 -2
- package/src/tests/grader-loader.spec.ts +5 -5
- package/src/tests/headless.spec.ts +460 -0
- package/src/tests/schemas-cli.spec.ts +142 -0
- package/src/tests/schemas.spec.ts +657 -0
- package/src/tests/summarize-helpers.spec.ts +3 -3
- package/src/tests/trials-cli.spec.ts +145 -0
- package/src/trials.ts +6 -19
- package/src/validate-refs.ts +1 -1
- package/src/tests/acp-integration.docker.ts +0 -214
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Headless ACP adapter factory CLI entry point.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* This module implements a schema-driven ACP adapter that can interact with
|
|
7
|
+
* ANY headless CLI agent. The adapter:
|
|
8
|
+
*
|
|
9
|
+
* 1. Reads a JSON schema defining how to interact with the CLI
|
|
10
|
+
* 2. Spawns the CLI process per schema's command + flags
|
|
11
|
+
* 3. Parses stdout using schema's outputEvents mappings
|
|
12
|
+
* 4. Emits ACP session/update notifications
|
|
13
|
+
* 5. Manages session state for multi-turn (stream or iterative mode)
|
|
14
|
+
*
|
|
15
|
+
* @packageDocumentation
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createInterface } from 'node:readline'
|
|
19
|
+
import { parseArgs } from 'node:util'
|
|
20
|
+
import { ACP_PROTOCOL_VERSION } from './constants.ts'
|
|
21
|
+
import { type HeadlessAdapterConfig, parseHeadlessConfig } from './headless.schemas.ts'
|
|
22
|
+
import { createSessionManager, type SessionManager } from './headless-session-manager.ts'
|
|
23
|
+
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// Types
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
/** JSON-RPC 2.0 request */
|
|
29
|
+
type JsonRpcRequest = {
|
|
30
|
+
jsonrpc: '2.0'
|
|
31
|
+
id: string | number
|
|
32
|
+
method: string
|
|
33
|
+
params?: unknown
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** JSON-RPC 2.0 notification */
|
|
37
|
+
type JsonRpcNotification = {
|
|
38
|
+
jsonrpc: '2.0'
|
|
39
|
+
method: string
|
|
40
|
+
params?: unknown
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** JSON-RPC 2.0 success response */
|
|
44
|
+
type JsonRpcSuccessResponse = {
|
|
45
|
+
jsonrpc: '2.0'
|
|
46
|
+
id: string | number
|
|
47
|
+
result: unknown
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** JSON-RPC 2.0 error response */
|
|
51
|
+
type JsonRpcErrorResponse = {
|
|
52
|
+
jsonrpc: '2.0'
|
|
53
|
+
id: string | number | null
|
|
54
|
+
error: {
|
|
55
|
+
code: number
|
|
56
|
+
message: string
|
|
57
|
+
data?: unknown
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** JSON-RPC 2.0 response */
|
|
62
|
+
type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse
|
|
63
|
+
|
|
64
|
+
/** Content block for prompts */
|
|
65
|
+
type ContentBlock = { type: 'text'; text: string } | { type: 'image'; source: unknown }
|
|
66
|
+
|
|
67
|
+
// ============================================================================
|
|
68
|
+
// Message Sending
|
|
69
|
+
// ============================================================================
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sends a JSON-RPC message to stdout.
|
|
73
|
+
*/
|
|
74
|
+
const sendMessage = (message: JsonRpcResponse | JsonRpcNotification): void => {
|
|
75
|
+
// biome-ignore lint/suspicious/noConsole: Protocol output
|
|
76
|
+
console.log(JSON.stringify(message))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Sends a session update notification.
|
|
81
|
+
*/
|
|
82
|
+
const sendSessionUpdate = (sessionId: string, update: unknown): void => {
|
|
83
|
+
sendMessage({
|
|
84
|
+
jsonrpc: '2.0',
|
|
85
|
+
method: 'session/update',
|
|
86
|
+
params: { sessionId, update },
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ============================================================================
|
|
91
|
+
// Request Handlers
|
|
92
|
+
// ============================================================================
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Creates request handlers for the headless adapter.
|
|
96
|
+
*
|
|
97
|
+
* @param schema - Headless adapter configuration
|
|
98
|
+
* @param sessions - Session manager instance
|
|
99
|
+
*/
|
|
100
|
+
const createHandlers = (schema: HeadlessAdapterConfig, sessions: SessionManager) => {
|
|
101
|
+
/**
|
|
102
|
+
* Handle initialize request.
|
|
103
|
+
*/
|
|
104
|
+
const handleInitialize = async (params: unknown): Promise<unknown> => {
|
|
105
|
+
const { protocolVersion } = params as { protocolVersion: number }
|
|
106
|
+
|
|
107
|
+
if (protocolVersion !== ACP_PROTOCOL_VERSION) {
|
|
108
|
+
throw new Error(`Unsupported protocol version: ${protocolVersion}`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
113
|
+
agentInfo: {
|
|
114
|
+
name: schema.name,
|
|
115
|
+
version: '1.0.0',
|
|
116
|
+
},
|
|
117
|
+
agentCapabilities: {
|
|
118
|
+
loadSession: !!schema.resume,
|
|
119
|
+
promptCapabilities: {
|
|
120
|
+
image: false,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Handle session/new request.
|
|
128
|
+
*/
|
|
129
|
+
const handleSessionNew = async (params: unknown): Promise<unknown> => {
|
|
130
|
+
const { cwd } = params as { cwd: string }
|
|
131
|
+
const session = await sessions.create(cwd)
|
|
132
|
+
return { sessionId: session.id }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Handle session/load request.
|
|
137
|
+
*/
|
|
138
|
+
const handleSessionLoad = async (params: unknown): Promise<unknown> => {
|
|
139
|
+
const { sessionId } = params as { sessionId: string }
|
|
140
|
+
const session = sessions.get(sessionId)
|
|
141
|
+
|
|
142
|
+
if (!session) {
|
|
143
|
+
throw new Error(`Session not found: ${sessionId}`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { sessionId }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Handle session/prompt request.
|
|
151
|
+
*/
|
|
152
|
+
const handleSessionPrompt = async (params: unknown): Promise<unknown> => {
|
|
153
|
+
const { sessionId, prompt } = params as { sessionId: string; prompt: ContentBlock[] }
|
|
154
|
+
|
|
155
|
+
// Extract text from content blocks
|
|
156
|
+
const promptText = prompt
|
|
157
|
+
.filter((block): block is ContentBlock & { type: 'text' } => block.type === 'text')
|
|
158
|
+
.map((block) => block.text)
|
|
159
|
+
.join('\n')
|
|
160
|
+
|
|
161
|
+
// Execute prompt and stream updates
|
|
162
|
+
const result = await sessions.prompt(sessionId, promptText, (update) => {
|
|
163
|
+
// Map parsed update to ACP session update format
|
|
164
|
+
const acpUpdate = mapToACPUpdate(update)
|
|
165
|
+
sendSessionUpdate(sessionId, acpUpdate)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
content: [{ type: 'text', text: result.output }],
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Handle session/cancel notification.
|
|
175
|
+
*/
|
|
176
|
+
const handleSessionCancel = async (params: unknown): Promise<void> => {
|
|
177
|
+
const { sessionId } = params as { sessionId: string }
|
|
178
|
+
sessions.cancel(sessionId)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
handleInitialize,
|
|
183
|
+
handleSessionNew,
|
|
184
|
+
handleSessionLoad,
|
|
185
|
+
handleSessionPrompt,
|
|
186
|
+
handleSessionCancel,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Maps a parsed update to ACP session update format.
|
|
192
|
+
*/
|
|
193
|
+
const mapToACPUpdate = (update: { type: string; content?: string; title?: string; status?: string }): unknown => {
|
|
194
|
+
switch (update.type) {
|
|
195
|
+
case 'thought':
|
|
196
|
+
return {
|
|
197
|
+
sessionUpdate: 'agent_thought_chunk',
|
|
198
|
+
content: { type: 'text', text: update.content ?? '' },
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
case 'message':
|
|
202
|
+
return {
|
|
203
|
+
sessionUpdate: 'agent_message_chunk',
|
|
204
|
+
content: { type: 'text', text: update.content ?? '' },
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
case 'tool_call':
|
|
208
|
+
return {
|
|
209
|
+
sessionUpdate: 'agent_tool_call',
|
|
210
|
+
toolCall: {
|
|
211
|
+
name: update.title ?? 'unknown',
|
|
212
|
+
status: update.status ?? 'pending',
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
case 'plan':
|
|
217
|
+
return {
|
|
218
|
+
sessionUpdate: 'agent_plan',
|
|
219
|
+
content: { type: 'text', text: update.content ?? '' },
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
default:
|
|
223
|
+
return {
|
|
224
|
+
sessionUpdate: 'agent_message_chunk',
|
|
225
|
+
content: { type: 'text', text: update.content ?? '' },
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ============================================================================
|
|
231
|
+
// Main Loop
|
|
232
|
+
// ============================================================================
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Runs the headless adapter main loop.
|
|
236
|
+
*
|
|
237
|
+
* @param schema - Headless adapter configuration
|
|
238
|
+
*/
|
|
239
|
+
const runAdapter = async (schema: HeadlessAdapterConfig): Promise<void> => {
|
|
240
|
+
const sessions = createSessionManager({ schema })
|
|
241
|
+
const handlers = createHandlers(schema, sessions)
|
|
242
|
+
|
|
243
|
+
// Method handlers (requests expect responses)
|
|
244
|
+
const methodHandlers: Record<string, (params: unknown) => Promise<unknown>> = {
|
|
245
|
+
initialize: handlers.handleInitialize,
|
|
246
|
+
'session/new': handlers.handleSessionNew,
|
|
247
|
+
'session/load': handlers.handleSessionLoad,
|
|
248
|
+
'session/prompt': handlers.handleSessionPrompt,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Notification handlers (no response expected)
|
|
252
|
+
const notificationHandlers: Record<string, (params: unknown) => Promise<void>> = {
|
|
253
|
+
'session/cancel': handlers.handleSessionCancel,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Process incoming JSON-RPC message.
|
|
258
|
+
*/
|
|
259
|
+
const processMessage = async (line: string): Promise<void> => {
|
|
260
|
+
let request: JsonRpcRequest | JsonRpcNotification
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
request = JSON.parse(line)
|
|
264
|
+
} catch {
|
|
265
|
+
sendMessage({
|
|
266
|
+
jsonrpc: '2.0',
|
|
267
|
+
id: null,
|
|
268
|
+
error: { code: -32700, message: 'Parse error' },
|
|
269
|
+
})
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Check if it's a notification (no id)
|
|
274
|
+
const isNotification = !('id' in request)
|
|
275
|
+
|
|
276
|
+
if (isNotification) {
|
|
277
|
+
const handler = notificationHandlers[request.method]
|
|
278
|
+
if (handler) {
|
|
279
|
+
await handler(request.params)
|
|
280
|
+
}
|
|
281
|
+
// No response for notifications
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// It's a request - send response
|
|
286
|
+
const reqWithId = request as JsonRpcRequest
|
|
287
|
+
const handler = methodHandlers[reqWithId.method]
|
|
288
|
+
|
|
289
|
+
if (!handler) {
|
|
290
|
+
sendMessage({
|
|
291
|
+
jsonrpc: '2.0',
|
|
292
|
+
id: reqWithId.id,
|
|
293
|
+
error: { code: -32601, message: `Method not found: ${reqWithId.method}` },
|
|
294
|
+
})
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
const result = await handler(reqWithId.params)
|
|
300
|
+
sendMessage({
|
|
301
|
+
jsonrpc: '2.0',
|
|
302
|
+
id: reqWithId.id,
|
|
303
|
+
result,
|
|
304
|
+
})
|
|
305
|
+
} catch (error) {
|
|
306
|
+
sendMessage({
|
|
307
|
+
jsonrpc: '2.0',
|
|
308
|
+
id: reqWithId.id,
|
|
309
|
+
error: {
|
|
310
|
+
code: -32603,
|
|
311
|
+
message: error instanceof Error ? error.message : 'Internal error',
|
|
312
|
+
},
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Main loop: read lines from stdin
|
|
318
|
+
const rl = createInterface({
|
|
319
|
+
input: process.stdin,
|
|
320
|
+
output: process.stdout,
|
|
321
|
+
terminal: false,
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
rl.on('line', processMessage)
|
|
325
|
+
|
|
326
|
+
// Handle clean shutdown
|
|
327
|
+
process.on('SIGTERM', () => {
|
|
328
|
+
rl.close()
|
|
329
|
+
process.exit(0)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
process.on('SIGINT', () => {
|
|
333
|
+
rl.close()
|
|
334
|
+
process.exit(0)
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ============================================================================
|
|
339
|
+
// CLI Entry Point
|
|
340
|
+
// ============================================================================
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Headless adapter CLI entry point.
|
|
344
|
+
*
|
|
345
|
+
* @param args - Command line arguments
|
|
346
|
+
*/
|
|
347
|
+
export const headless = async (args: string[]): Promise<void> => {
|
|
348
|
+
const { values } = parseArgs({
|
|
349
|
+
args,
|
|
350
|
+
options: {
|
|
351
|
+
schema: { type: 'string', short: 's' },
|
|
352
|
+
help: { type: 'boolean', short: 'h' },
|
|
353
|
+
},
|
|
354
|
+
allowPositionals: false,
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
if (values.help) {
|
|
358
|
+
// biome-ignore lint/suspicious/noConsole: CLI help output
|
|
359
|
+
console.log(`
|
|
360
|
+
Usage: acp-harness headless --schema <path>
|
|
361
|
+
|
|
362
|
+
Arguments:
|
|
363
|
+
-s, --schema Path to headless adapter schema (JSON)
|
|
364
|
+
-h, --help Show this help message
|
|
365
|
+
|
|
366
|
+
Description:
|
|
367
|
+
Schema-driven ACP adapter for ANY headless CLI agent. The adapter reads
|
|
368
|
+
a JSON schema defining how to interact with the CLI and translates between
|
|
369
|
+
ACP protocol and CLI stdio.
|
|
370
|
+
|
|
371
|
+
Schema Format:
|
|
372
|
+
{
|
|
373
|
+
"version": 1,
|
|
374
|
+
"name": "my-agent",
|
|
375
|
+
"command": ["my-agent-cli"],
|
|
376
|
+
"sessionMode": "stream" | "iterative",
|
|
377
|
+
"prompt": { "flag": "-p" },
|
|
378
|
+
"output": { "flag": "--output-format", "value": "stream-json" },
|
|
379
|
+
"outputEvents": [...],
|
|
380
|
+
"result": { "matchPath": "$.type", "matchValue": "result", "contentPath": "$.content" }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
Examples:
|
|
384
|
+
# Run with Claude headless schema
|
|
385
|
+
acp-harness headless --schema ./claude-headless.json
|
|
386
|
+
|
|
387
|
+
# Use in capture pipeline
|
|
388
|
+
acp-harness capture prompts.jsonl \\
|
|
389
|
+
acp-harness headless --schema ./claude-headless.json \\
|
|
390
|
+
-o results.jsonl
|
|
391
|
+
|
|
392
|
+
# Validate adapter compliance
|
|
393
|
+
acp-harness adapter:check \\
|
|
394
|
+
acp-harness headless --schema ./gemini-headless.json
|
|
395
|
+
`)
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (!values.schema) {
|
|
400
|
+
console.error('Error: --schema is required')
|
|
401
|
+
console.error('Example: acp-harness headless --schema ./my-agent.json')
|
|
402
|
+
process.exit(1)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Load and validate schema
|
|
406
|
+
const schemaPath = values.schema
|
|
407
|
+
const schemaFile = Bun.file(schemaPath)
|
|
408
|
+
|
|
409
|
+
if (!(await schemaFile.exists())) {
|
|
410
|
+
console.error(`Error: schema file not found: ${schemaPath}`)
|
|
411
|
+
process.exit(1)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
let schema: HeadlessAdapterConfig
|
|
415
|
+
try {
|
|
416
|
+
const rawSchema = await schemaFile.json()
|
|
417
|
+
schema = parseHeadlessConfig(rawSchema)
|
|
418
|
+
} catch (error) {
|
|
419
|
+
console.error(`Error: invalid schema: ${error instanceof Error ? error.message : String(error)}`)
|
|
420
|
+
process.exit(1)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Run the adapter
|
|
424
|
+
await runAdapter(schema)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Allow direct execution
|
|
428
|
+
if (import.meta.main) {
|
|
429
|
+
headless(Bun.argv.slice(2)).catch((error) => {
|
|
430
|
+
console.error('Error:', error instanceof Error ? error.message : error)
|
|
431
|
+
process.exit(1)
|
|
432
|
+
})
|
|
433
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* History builder for iterative mode sessions.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* In iterative mode, each prompt spawns a new process. The history builder
|
|
6
|
+
* accumulates conversation context and formats it using the schema's
|
|
7
|
+
* historyTemplate for inclusion in subsequent prompts.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// Types
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
/** A single turn in conversation history */
|
|
17
|
+
export type HistoryTurn = {
|
|
18
|
+
/** User input */
|
|
19
|
+
input: string
|
|
20
|
+
/** Agent output */
|
|
21
|
+
output: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** History builder configuration */
|
|
25
|
+
export type HistoryBuilderConfig = {
|
|
26
|
+
/** Template for formatting history (e.g., "User: {{input}}\nAssistant: {{output}}") */
|
|
27
|
+
template?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// Default Template
|
|
32
|
+
// ============================================================================
|
|
33
|
+
|
|
34
|
+
const DEFAULT_TEMPLATE = 'User: {{input}}\nAssistant: {{output}}'
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// History Builder Factory
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Creates a history builder for iterative mode sessions.
|
|
42
|
+
*
|
|
43
|
+
* @remarks
|
|
44
|
+
* The history builder:
|
|
45
|
+
* 1. Stores conversation turns
|
|
46
|
+
* 2. Formats history using the template
|
|
47
|
+
* 3. Builds complete prompts with context
|
|
48
|
+
*
|
|
49
|
+
* @param config - History builder configuration
|
|
50
|
+
* @returns History builder with add, format, and build methods
|
|
51
|
+
*/
|
|
52
|
+
export const createHistoryBuilder = (config: HistoryBuilderConfig = {}) => {
|
|
53
|
+
const template = config.template ?? DEFAULT_TEMPLATE
|
|
54
|
+
const history: HistoryTurn[] = []
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Adds a turn to history.
|
|
58
|
+
*
|
|
59
|
+
* @param input - User input
|
|
60
|
+
* @param output - Agent output
|
|
61
|
+
*/
|
|
62
|
+
const addTurn = (input: string, output: string): void => {
|
|
63
|
+
history.push({ input, output })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Formats the current history as a string.
|
|
68
|
+
*
|
|
69
|
+
* @returns Formatted history string
|
|
70
|
+
*/
|
|
71
|
+
const formatHistory = (): string => {
|
|
72
|
+
return history.map((turn) => formatTurn(turn, template)).join('\n\n')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Builds a prompt with history context.
|
|
77
|
+
*
|
|
78
|
+
* @remarks
|
|
79
|
+
* For the first turn, returns just the input.
|
|
80
|
+
* For subsequent turns, prepends formatted history.
|
|
81
|
+
*
|
|
82
|
+
* @param newInput - The new user input
|
|
83
|
+
* @returns Full prompt including history context
|
|
84
|
+
*/
|
|
85
|
+
const buildPrompt = (newInput: string): string => {
|
|
86
|
+
if (history.length === 0) {
|
|
87
|
+
return newInput
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const formattedHistory = formatHistory()
|
|
91
|
+
return `${formattedHistory}\n\nUser: ${newInput}`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Gets the number of turns in history.
|
|
96
|
+
*/
|
|
97
|
+
const getLength = (): number => {
|
|
98
|
+
return history.length
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Clears all history.
|
|
103
|
+
*/
|
|
104
|
+
const clear = (): void => {
|
|
105
|
+
history.length = 0
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Gets a copy of the history.
|
|
110
|
+
*/
|
|
111
|
+
const getHistory = (): HistoryTurn[] => {
|
|
112
|
+
return [...history]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
addTurn,
|
|
117
|
+
formatHistory,
|
|
118
|
+
buildPrompt,
|
|
119
|
+
getLength,
|
|
120
|
+
clear,
|
|
121
|
+
getHistory,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ============================================================================
|
|
126
|
+
// Helper Functions
|
|
127
|
+
// ============================================================================
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Formats a single turn using the template.
|
|
131
|
+
*
|
|
132
|
+
* @param turn - History turn
|
|
133
|
+
* @param template - Template string with {{input}} and {{output}} placeholders
|
|
134
|
+
* @returns Formatted turn string
|
|
135
|
+
*/
|
|
136
|
+
const formatTurn = (turn: HistoryTurn, template: string): string => {
|
|
137
|
+
return template.replace('{{input}}', turn.input).replace('{{output}}', turn.output)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** History builder type */
|
|
141
|
+
export type HistoryBuilder = ReturnType<typeof createHistoryBuilder>
|