Skip to content

Commit 619ae64

Browse files
pokeyhntrl
andauthored
feat(core): Add BaseMessage.toFormattedString() (#9228)
Co-authored-by: Hunter Lovell <[email protected]> Co-authored-by: Hunter Lovell <[email protected]>
1 parent dda9ea4 commit 619ae64

File tree

4 files changed

+233
-0
lines changed

4 files changed

+233
-0
lines changed

.changeset/metal-hats-camp.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@langchain/core": patch
3+
---
4+
5+
Add `BaseMessage.toFormattedString()`

libs/langchain-core/src/messages/base.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import {
1212
isMessage,
1313
Message,
1414
} from "./message.js";
15+
import {
16+
convertToFormattedString,
17+
type MessageStringFormat,
18+
} from "./format.js";
1519

1620
/** @internal */
1721
const MESSAGE_SYMBOL = Symbol.for("langchain.message");
@@ -378,6 +382,10 @@ export abstract class BaseMessage<
378382
// eslint-disable-next-line @typescript-eslint/no-explicit-any
379383
return `${(this.constructor as any).lc_name()} ${printable}`;
380384
}
385+
386+
toFormattedString(format: MessageStringFormat = "pretty"): string {
387+
return convertToFormattedString(this, format);
388+
}
381389
}
382390

383391
/**
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { type BaseMessage } from "./base.js";
2+
import { type AIMessage } from "./ai.js";
3+
import { type ToolMessage } from "./tool.js";
4+
5+
export type MessageStringFormat = "pretty";
6+
7+
export function convertToFormattedString(
8+
message: BaseMessage,
9+
format: MessageStringFormat = "pretty"
10+
): string {
11+
if (format === "pretty") return convertToPrettyString(message);
12+
return JSON.stringify(message);
13+
}
14+
15+
function convertToPrettyString(message: BaseMessage): string {
16+
const lines: string[] = [];
17+
const title = ` ${
18+
message.type.charAt(0).toUpperCase() + message.type.slice(1)
19+
} Message `;
20+
const sepLen = Math.floor((80 - title.length) / 2);
21+
const sep = "=".repeat(sepLen);
22+
const secondSep = title.length % 2 === 0 ? sep : `${sep}=`;
23+
lines.push(`${sep}${title}${secondSep}`);
24+
25+
// Add message type specific details
26+
if (message.type === "ai") {
27+
const aiMessage = message as AIMessage;
28+
if (aiMessage.tool_calls && aiMessage.tool_calls.length > 0) {
29+
lines.push("Tool Calls:");
30+
for (const tc of aiMessage.tool_calls) {
31+
lines.push(` ${tc.name} (${tc.id})`);
32+
lines.push(` Call ID: ${tc.id}`);
33+
lines.push(" Args:");
34+
for (const [key, value] of Object.entries(tc.args)) {
35+
lines.push(` ${key}: ${value}`);
36+
}
37+
}
38+
}
39+
}
40+
if (message.type === "tool") {
41+
const toolMessage = message as ToolMessage;
42+
if (toolMessage.name) {
43+
lines.push(`Name: ${toolMessage.name}`);
44+
}
45+
}
46+
47+
// Add content if it's a string and not empty
48+
if (typeof message.content === "string" && message.content.trim()) {
49+
if (lines.length > 1) {
50+
lines.push(""); // blank line before content
51+
}
52+
lines.push(message.content);
53+
}
54+
55+
return lines.join("\n");
56+
}

libs/langchain-core/src/messages/tests/base_message.test.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,3 +754,167 @@ describe("usage_metadata serialized", () => {
754754
expect(jsonConcatenatedAIMessageChunk).toContain("total_tokens");
755755
});
756756
});
757+
758+
describe("toFormattedString", () => {
759+
describe("BaseMessage (HumanMessage)", () => {
760+
it("formats a simple string message", () => {
761+
const message = new HumanMessage("Hello, world!");
762+
const output = message.toFormattedString();
763+
expect(output).toContain("Human Message");
764+
expect(output).toContain("Hello, world!");
765+
expect(output).toMatch(/={30,}/); // Check for separator line
766+
});
767+
768+
it("formats a message with empty content", () => {
769+
const message = new HumanMessage("");
770+
const output = message.toFormattedString();
771+
expect(output).toContain("Human Message");
772+
expect(output).not.toContain("\n\n"); // No blank line before content
773+
});
774+
775+
it("formats a message with whitespace-only content", () => {
776+
const message = new HumanMessage(" ");
777+
const output = message.toFormattedString();
778+
expect(output).toContain("Human Message");
779+
// Whitespace-only content should be treated as empty
780+
expect(output.split("\n").length).toBe(1);
781+
});
782+
});
783+
784+
describe("AIMessage", () => {
785+
it("formats an AI message without tool calls", () => {
786+
const message = new AIMessage("I can help with that!");
787+
const output = message.toFormattedString();
788+
expect(output).toContain("Ai Message");
789+
expect(output).toContain("I can help with that!");
790+
});
791+
792+
it("formats an AI message with tool calls", () => {
793+
const message = new AIMessage({
794+
content: "Let me check the weather",
795+
tool_calls: [
796+
{
797+
id: "call_123",
798+
name: "get_weather",
799+
args: { location: "San Francisco", unit: "celsius" },
800+
type: "tool_call",
801+
},
802+
],
803+
});
804+
const output = message.toFormattedString();
805+
expect(output).toContain("Ai Message");
806+
expect(output).toContain("Tool Calls:");
807+
expect(output).toContain("get_weather (call_123)");
808+
expect(output).toContain("Call ID: call_123");
809+
expect(output).toContain("Args:");
810+
expect(output).toContain("location: San Francisco");
811+
expect(output).toContain("unit: celsius");
812+
});
813+
814+
it("formats an AI message with multiple tool calls", () => {
815+
const message = new AIMessage({
816+
content: "",
817+
tool_calls: [
818+
{
819+
id: "call_1",
820+
name: "search",
821+
args: { query: "test" },
822+
type: "tool_call",
823+
},
824+
{
825+
id: "call_2",
826+
name: "calculator",
827+
args: { expression: "2+2" },
828+
type: "tool_call",
829+
},
830+
],
831+
});
832+
const output = message.toFormattedString();
833+
expect(output).toContain("search (call_1)");
834+
expect(output).toContain("calculator (call_2)");
835+
});
836+
837+
it("formats an AI message with empty tool calls array", () => {
838+
const message = new AIMessage({
839+
content: "Just a message",
840+
tool_calls: [],
841+
});
842+
const output = message.toFormattedString();
843+
expect(output).toContain("Ai Message");
844+
expect(output).not.toContain("Tool Calls:");
845+
expect(output).toContain("Just a message");
846+
});
847+
});
848+
849+
describe("ToolMessage", () => {
850+
it("formats a tool message with name", () => {
851+
const message = new ToolMessage({
852+
content: '{"temperature": 72}',
853+
tool_call_id: "call_123",
854+
name: "get_weather",
855+
});
856+
const output = message.toFormattedString();
857+
expect(output).toContain("Tool Message");
858+
expect(output).toContain("Name: get_weather");
859+
expect(output).toContain('{"temperature": 72}');
860+
});
861+
862+
it("formats a tool message without name", () => {
863+
const message = new ToolMessage({
864+
content: "Success",
865+
tool_call_id: "call_456",
866+
});
867+
const output = message.toFormattedString();
868+
expect(output).toContain("Tool Message");
869+
expect(output).not.toContain("Name:");
870+
expect(output).toContain("Success");
871+
});
872+
});
873+
874+
describe("SystemMessage", () => {
875+
it("formats a system message", () => {
876+
const message = new SystemMessage("You are a helpful assistant.");
877+
const output = message.toFormattedString();
878+
expect(output).toContain("System Message");
879+
expect(output).toContain("You are a helpful assistant.");
880+
});
881+
});
882+
883+
describe("Message formatting consistency", () => {
884+
it("maintains consistent separator length for different message types", () => {
885+
const human = new HumanMessage("Hi");
886+
const ai = new AIMessage("Hello");
887+
const system = new SystemMessage("System");
888+
889+
const humanOutput = human.toFormattedString();
890+
const aiOutput = ai.toFormattedString();
891+
const systemOutput = system.toFormattedString();
892+
893+
const humanSep = humanOutput.split("\n")[0];
894+
const aiSep = aiOutput.split("\n")[0];
895+
const systemSep = systemOutput.split("\n")[0];
896+
897+
expect(humanSep.length).toBe(80);
898+
expect(aiSep.length).toBe(80);
899+
expect(systemSep.length).toBe(80);
900+
});
901+
902+
it("adds blank line before content when details are present", () => {
903+
const messageWithDetails = new AIMessage({
904+
content: "Response",
905+
tool_calls: [
906+
{
907+
id: "call_1",
908+
name: "tool",
909+
args: {},
910+
type: "tool_call",
911+
},
912+
],
913+
});
914+
const output = messageWithDetails.toFormattedString();
915+
const lines = output.split("\n");
916+
// Should have: title, Tool Calls:, tool info, blank line, content
917+
expect(lines).toContain("");
918+
});
919+
});
920+
});

0 commit comments

Comments
 (0)