-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathrepl.ts
More file actions
107 lines (94 loc) · 3.66 KB
/
Copy pathrepl.ts
File metadata and controls
107 lines (94 loc) · 3.66 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
/**
* A tiny REPL that renders a LangChain/LangGraph agent streaming response.
*
* This is boilerplate — the interesting parts (Mesa setup, tool definition, agent config)
* live in index.ts. This file just handles readline and stream chunk rendering.
*/
import * as readline from 'node:readline';
import type { BaseMessage } from '@langchain/core/messages';
import { HumanMessage } from '@langchain/core/messages';
import type { StreamMode } from '@langchain/langgraph';
import { z } from 'zod';
const bashOutputSchema = z.object({
stdout: z.string(),
stderr: z.string(),
exitCode: z.number(),
});
function truncate(text: string, maxLines = 10): string {
const lines = text.trimEnd().split('\n');
if (lines.length <= maxLines) return text.trimEnd();
return `${lines.slice(0, maxLines).join('\n')}\n ... (${lines.length - maxLines} more lines)`;
}
function question(rl: readline.Interface, prompt: string): Promise<string | null> {
return new Promise((resolve) => {
rl.once('close', () => resolve(null));
rl.question(prompt, (answer) => resolve(answer));
});
}
interface LangChainAgent {
// oxlint-disable-next-line typescript/no-explicit-any -- LangGraph stream yields heterogeneous tuples whose shapes vary by mode
stream(input: { messages: BaseMessage[] }, opts: { streamMode: StreamMode[] }): Promise<AsyncIterable<[string, any]>>;
}
export async function langchainRepl(agent: LangChainAgent): Promise<void> {
let messages: BaseMessage[] = [];
let lastBlockType: string | undefined;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
while (true) {
const input = await question(rl, '> ');
if (input === null) break;
const trimmed = input.trim();
if (!trimmed) continue;
if (trimmed === 'exit') break;
messages.push(new HumanMessage(trimmed));
// LangGraph uses a dual-stream approach:
// "values" = full graph state after each step (canonical history for the next turn)
// "messages" = token-level chunks for rendering in the UI
//
// Do not persist "messages" stream chunks: they are partial and may be empty text blocks,
// which Anthropic rejects on the following request.
for await (const [mode, data] of await agent.stream(
{ messages },
{ streamMode: ['values', 'messages'] satisfies StreamMode[] }
)) {
if (mode === 'values') {
messages = data.messages;
continue;
}
if (mode === 'messages') {
const [chunk] = data;
for (const block of chunk.contentBlocks) {
switch (block.type) {
case 'reasoning': {
if (lastBlockType !== 'reasoning') console.log('\n--- thinking ---');
process.stdout.write(truncate(block.reasoning));
lastBlockType = block.type;
break;
}
case 'text': {
if (chunk.type === 'tool') {
const { stdout, stderr, exitCode } = bashOutputSchema.parse(JSON.parse(block.text));
if (stdout) console.log(`\n${truncate(stdout)}`);
if (stderr) console.log(`\n${truncate(stderr)}`);
if (exitCode !== 0) console.error(`\n[exit ${exitCode}]`);
lastBlockType = 'tool_call_result';
break;
}
if (lastBlockType !== 'text') console.log('\n');
process.stdout.write(block.text);
lastBlockType = block.type;
break;
}
case 'tool_call': {
console.log(`\n[bash] ${block.name}`);
lastBlockType = block.type;
break;
}
}
}
}
}
}
}