package peg_test import ( "github.com/mudler/LocalAI/pkg/functions/peg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) func createTools() []peg.ToolDef { return []peg.ToolDef{ { Name: "get_current_weather", Properties: map[string]peg.PropDef{ "location": {Type: "string"}, "unit": {Type: "string"}, }, }, { Name: "get_forecast", Properties: map[string]peg.PropDef{ "location": {Type: "string"}, "unit": {Type: "string"}, "days": {Type: "integer"}, }, }, { Name: "search_knowledge_base", Properties: map[string]peg.PropDef{ "query": {Type: "string"}, "max_results": {Type: "integer"}, "category": {Type: "string"}, }, }, } } func simpleTokenize(input string) []string { var result []string var current string for _, c := range input { switch c { case ' ', '\n', '\t', '{', '}', ',', '[', '"', ']', '.', '<', '>', '=', '/': if current != "" { result = append(result, current) current = "" } } current += string(c) } if current != "" { result = append(result, current) } return result } var _ = Describe("Chat PEG Parser", func() { Context("ExampleNative", func() { type testCase struct { name string tools []peg.ToolDef reasoningFormat string parallelCalls bool forcedOpen bool forceToolCalls bool input string expectReasoning string expectContent string expectToolCalls []peg.ToolCall } buildParser := func(tc testCase) *peg.Arena { return peg.BuildChatPegParser(func(p *peg.ChatBuilder) peg.ParserID { reasoningInContent := tc.reasoningFormat == "none" var reasoning peg.ParserID if tc.forcedOpen { reasoning = p.Seq( p.Reasoning(p.Until("")), p.Literal(""), p.Space(), ) } else { reasoning = p.Optional(p.Seq( p.Literal(""), p.Reasoning(p.Until("")), p.Literal(""), p.Space(), )) } if len(tc.tools) > 0 { toolCall := p.StandardJSONTools(peg.StandardJSONToolsOpts{ SectionStart: "[", SectionEnd: "]", Tools: tc.tools, ParallelCalls: tc.parallelCalls, ForceToolCalls: tc.forceToolCalls, }) var parts []peg.ParserID if reasoningInContent { parts = append(parts, p.Eps()) } else { parts = append(parts, reasoning) } parts = append(parts, p.Content(p.Until("")), p.Optional(p.Seq(p.Space(), toolCall)), p.Space(), p.End(), ) return p.Seq(parts...) } var parts []peg.ParserID if reasoningInContent { parts = append(parts, p.Eps()) } else { parts = append(parts, reasoning) } parts = append(parts, p.Content(p.Rest()), p.End()) return p.Seq(parts...) }) } DescribeTable("native format cases", func(tc testCase) { parser := buildParser(tc) ctx := peg.NewParseContext(tc.input, false) result := parser.Parse(ctx) Expect(result.Type).To(Equal(peg.Success)) mapper := &peg.ChatPegMapper{} mapper.FromAST(&ctx.Ast, &result) msg := mapper.Result Expect(msg.Content).To(Equal(tc.expectContent)) Expect(msg.ReasoningContent).To(Equal(tc.expectReasoning)) Expect(msg.ToolCalls).To(HaveLen(len(tc.expectToolCalls))) for i := 0; i < len(tc.expectToolCalls) && i < len(msg.ToolCalls); i++ { Expect(msg.ToolCalls[i].Name).To(Equal(tc.expectToolCalls[i].Name)) Expect(msg.ToolCalls[i].Arguments).To(Equal(tc.expectToolCalls[i].Arguments)) } }, Entry("content with thinking", testCase{ reasoningFormat: "auto", input: "The user said hello, I must say hello back\nHello", expectReasoning: "The user said hello, I must say hello back", expectContent: "Hello", }), Entry("content without thinking", testCase{ reasoningFormat: "auto", input: "Hello", expectContent: "Hello", }), Entry("content with reasoning_format = none", testCase{ reasoningFormat: "none", forcedOpen: true, input: "The user said hello, I must say hello back\nHello", expectContent: "The user said hello, I must say hello back\nHello", }), Entry("content with forced_open", testCase{ reasoningFormat: "auto", forcedOpen: true, input: "The user said hello, I must say hello back\nHello", expectReasoning: "The user said hello, I must say hello back", expectContent: "Hello", }), Entry("content with forced_open and reasoning_format = none", testCase{ reasoningFormat: "none", forcedOpen: true, input: "The user said hello, I must say hello back\nHello", expectContent: "The user said hello, I must say hello back\nHello", }), Entry("single tool call", testCase{ tools: createTools(), reasoningFormat: "auto", forcedOpen: true, input: "I must get the weather in New York\n" + "[" + `{"name": "get_current_weather", "arguments": {"location": "New York City, NY", "unit": "fahrenheit"}}` + "]", expectReasoning: "I must get the weather in New York", expectToolCalls: []peg.ToolCall{ { Name: "get_current_weather", Arguments: `{"location": "New York City, NY", "unit": "fahrenheit"}`, }, }, }), Entry("parallel tool calls", testCase{ tools: createTools(), reasoningFormat: "auto", parallelCalls: true, forcedOpen: true, input: "I must get the weather in New York and San Francisco and a 3 day forecast of each.\nLet me search that for you." + "[" + `{"name": "get_current_weather", "arguments": {"location": "New York City, NY", "unit": "fahrenheit"}}` + ", " + `{"name": "get_current_weather", "arguments": {"location": "San Francisco, CA", "unit": "fahrenheit"}}` + ", " + `{"name": "get_forecast", "arguments": {"location": "New York City, NY", "unit": "fahrenheit", "days": 3}}` + ", " + `{"name": "get_forecast", "arguments": {"location": "San Francisco, CA", "unit": "fahrenheit", "days": 3}}` + "]", expectReasoning: "I must get the weather in New York and San Francisco and a 3 day forecast of each.", expectContent: "Let me search that for you.", expectToolCalls: []peg.ToolCall{ {Name: "get_current_weather", Arguments: `{"location": "New York City, NY", "unit": "fahrenheit"}`}, {Name: "get_current_weather", Arguments: `{"location": "San Francisco, CA", "unit": "fahrenheit"}`}, {Name: "get_forecast", Arguments: `{"location": "New York City, NY", "unit": "fahrenheit", "days": 3}`}, {Name: "get_forecast", Arguments: `{"location": "San Francisco, CA", "unit": "fahrenheit", "days": 3}`}, }, }), Entry("JSON schema response format", testCase{ tools: createTools(), reasoningFormat: "auto", forcedOpen: true, forceToolCalls: false, input: "Thinking about the answer\n" + `[{"name": "get_current_weather", "arguments": {"location": "NYC", "unit": "celsius"}}]`, expectReasoning: "Thinking about the answer", expectToolCalls: []peg.ToolCall{ {Name: "get_current_weather", Arguments: `{"location": "NYC", "unit": "celsius"}`}, }, }), ) }) Context("ExampleQwen3Coder", func() { It("parses tool calls with tagged parameters", func() { tools := createTools() parser := peg.BuildChatPegParser(func(p *peg.ChatBuilder) peg.ParserID { content := p.Rule("content", p.Content(p.Until(""))) var toolParsers []peg.ParserID for _, tool := range tools { var argChoices []peg.ParserID for propName, prop := range tool.Properties { var argValueParser peg.ParserID if prop.Type == "string" { argValueParser = p.ToolArgStringValue(p.UntilOneOf("\n\n")) } else { argValueParser = p.ToolArgJSONValue(p.JSON()) } arg := p.ToolArg(p.Seq( p.ToolArgOpen(p.Literal("")), argValueParser, p.ToolArgClose(p.Seq( p.Literal("\n"), p.Peek(p.Choice(p.Literal(""))), )), )) argChoices = append(argChoices, arg) } argChoice := p.Choice(argChoices...) args := p.ZeroOrMore(argChoice) toolParser := p.Rule("tool-"+tool.Name, p.Seq( p.ToolOpen(p.Seq( p.Literal("\n"), )), args, p.ToolClose(p.Literal("")), )) toolParsers = append(toolParsers, toolParser) } toolCall := p.TriggerRule("tool-call", p.Seq( p.Literal(""), p.Space(), p.Choice(toolParsers...), p.Space(), p.Literal(""), )) return p.Seq(content, p.ZeroOrMore(p.Seq(p.Space(), toolCall)), p.End()) }) input := "Let me search the knowledge base for cat pictures." + "\n" + "\n" + "cat pictures\n" + "general\n" + "\n" + "" ctx := peg.NewParseContext(input, false) result := parser.Parse(ctx) Expect(result.Type).To(Equal(peg.Success)) mapper := &peg.ChatPegMapper{} mapper.FromAST(&ctx.Ast, &result) msg := mapper.Result Expect(msg.Content).To(Equal("Let me search the knowledge base for cat pictures.")) Expect(msg.ToolCalls).To(HaveLen(1)) Expect(msg.ToolCalls[0].Name).To(Equal("search_knowledge_base")) Expect(msg.ToolCalls[0].Arguments).NotTo(BeEmpty()) }) }) Context("ExampleQwen3NonCoder", func() { It("parses JSON tool calls", func() { tools := createTools() parser := peg.BuildChatPegParser(func(p *peg.ChatBuilder) peg.ParserID { toolCall := p.StandardJSONTools(peg.StandardJSONToolsOpts{ SectionStart: "", SectionEnd: "", Tools: tools, ParallelCalls: true, }) return p.Seq( p.Content(p.Until("")), p.Optional(p.Seq(p.Space(), toolCall)), p.End(), ) }) input := "I need to get the weather.\n" + "" + `{"name": "get_current_weather", "arguments": {"location": "New York City, NY", "unit": "fahrenheit"}}` + "" ctx := peg.NewParseContext(input, false) result := parser.Parse(ctx) Expect(result.Type).To(Equal(peg.Success)) mapper := &peg.ChatPegMapper{} mapper.FromAST(&ctx.Ast, &result) msg := mapper.Result Expect(msg.Content).To(Equal("I need to get the weather.\n")) Expect(msg.ReasoningContent).To(BeEmpty()) Expect(msg.ToolCalls).To(HaveLen(1)) Expect(msg.ToolCalls[0].Name).To(Equal("get_current_weather")) Expect(msg.ToolCalls[0].Arguments).To(Equal(`{"location": "New York City, NY", "unit": "fahrenheit"}`)) }) }) Context("Command7", func() { var parser *peg.Arena BeforeEach(func() { parser = peg.BuildChatPegParser(func(p *peg.ChatBuilder) peg.ParserID { thinking := p.ReasoningBlock(p.Seq( p.Literal("<|START_THINKING|>"), p.Space(), p.Reasoning(p.Until("<|END_THINKING|>")), p.Space(), p.Literal("<|END_THINKING|>"), )) response := p.Seq( p.Literal("<|START_RESPONSE|>"), p.Space(), p.Content(p.Until("<|END_RESPONSE|>")), p.Space(), p.Literal("<|END_RESPONSE|>"), ) toolCallID := p.Atomic(p.Seq( p.Literal("\"tool_call_id\""), p.Space(), p.Literal(":"), p.Space(), p.Literal("\""), p.ToolID(p.JSONString()), p.Literal("\""), )) toolCallName := p.Atomic(p.Seq( p.Literal("\"tool_name\""), p.Space(), p.Literal(":"), p.Space(), p.Literal("\""), p.ToolName(p.JSONString()), p.Literal("\""), )) toolCallArgs := p.Seq( p.Literal("\"parameters\""), p.Space(), p.Literal(":"), p.Space(), p.ToolArgs(p.JSON()), ) toolCallFields := p.Rule("tool-call-fields", p.Choice(toolCallID, toolCallName, toolCallArgs)) toolCall := p.Rule("tool-call-single", p.Tool(p.Seq( p.ToolOpen(p.Literal("{")), p.Space(), toolCallFields, p.ZeroOrMore(p.Seq(p.Literal(","), p.Space(), toolCallFields)), p.Space(), p.ToolClose(p.Literal("}")), ))) toolCalls := p.Rule("tool-calls", p.Seq( p.Literal("<|START_ACTION|>"), p.Space(), p.Literal("["), p.Space(), toolCall, p.ZeroOrMore(p.Seq(p.Literal(","), p.Space(), toolCall)), p.Space(), p.Literal("]"), p.Space(), p.Literal("<|END_ACTION|>"), )) return p.Seq( p.Optional(p.Seq(thinking, p.Space())), p.Choice(toolCalls, response), p.End(), ) }) }) It("parses tool call with reasoning", func() { input := "<|START_THINKING|>I need to plan a trip to Japan.\n<|END_THINKING|>" + "<|START_ACTION|>[" + `{"tool_call_id": "call_0", "tool_name": "plan_trip", "parameters": {"destination": "Japan", "duration": 14}}` + "]<|END_ACTION|>" ctx := peg.NewParseContext(input, false) result := parser.Parse(ctx) Expect(result.Type).To(Equal(peg.Success)) mapper := &peg.ChatPegMapper{} mapper.FromAST(&ctx.Ast, &result) msg := mapper.Result Expect(msg.ReasoningContent).To(Equal("I need to plan a trip to Japan.\n")) Expect(msg.ToolCalls).To(HaveLen(1)) Expect(msg.ToolCalls[0].Name).To(Equal("plan_trip")) Expect(msg.ToolCalls[0].ID).To(Equal("call_0")) }) It("parses content-only response", func() { input := "<|START_RESPONSE|>Hello, how can I help you?<|END_RESPONSE|>" ctx := peg.NewParseContext(input, false) result := parser.Parse(ctx) Expect(result.Type).To(Equal(peg.Success)) mapper := &peg.ChatPegMapper{} mapper.FromAST(&ctx.Ast, &result) msg := mapper.Result Expect(msg.Content).To(Equal("Hello, how can I help you?")) Expect(msg.ToolCalls).To(BeEmpty()) }) }) Context("PrefixToolNames", func() { var parser *peg.Arena BeforeEach(func() { tools := []peg.ToolDef{ {Name: "special_function", Properties: map[string]peg.PropDef{"arg1": {Type: "string"}}}, {Name: "special_function_with_opt", Properties: map[string]peg.PropDef{"arg1": {Type: "string"}}}, } parser = peg.BuildChatPegParser(func(p *peg.ChatBuilder) peg.ParserID { toolCall := p.StandardConstructedTools( map[string]string{}, tools, true, false, ) return p.Seq( p.Content(p.Until("")), p.Optional(p.Seq(p.Space(), toolCall)), p.End(), ) }) }) It("parses long tool name", func() { input := "Let me call the function.42" ctx := peg.NewParseContext(input, false) result := parser.Parse(ctx) Expect(result.Type).To(Equal(peg.Success)) mapper := &peg.ChatPegMapper{} mapper.FromAST(&ctx.Ast, &result) Expect(mapper.Result.ToolCalls).To(HaveLen(1)) Expect(mapper.Result.ToolCalls[0].Name).To(Equal("special_function_with_opt")) }) It("parses short tool name", func() { input := "Let me call the function.42" ctx := peg.NewParseContext(input, false) result := parser.Parse(ctx) Expect(result.Type).To(Equal(peg.Success)) mapper := &peg.ChatPegMapper{} mapper.FromAST(&ctx.Ast, &result) Expect(mapper.Result.ToolCalls).To(HaveLen(1)) Expect(mapper.Result.ToolCalls[0].Name).To(Equal("special_function")) }) It("never prematurely matches during incremental parsing", func() { input := "Let me call the function." + "" + "" + "42" + "" + "" tokens := simpleTokenize(input) var accumulated string for i, tok := range tokens { accumulated += tok isPartial := i < len(tokens)-1 ctx := peg.NewParseContext(accumulated, isPartial) result := parser.Parse(ctx) Expect(result.Type).NotTo(Equal(peg.Fail), "parse failed at token %d, input: %s", i, accumulated) mapper := &peg.ChatPegMapper{} mapper.FromAST(&ctx.Ast, &result) for _, tc := range mapper.Result.ToolCalls { Expect(tc.Name).NotTo(Equal("special_function"), "premature tool name match at token %d, input: %s", i, accumulated) } } ctx := peg.NewParseContext(input, false) result := parser.Parse(ctx) Expect(result.Type).To(Equal(peg.Success)) mapper := &peg.ChatPegMapper{} mapper.FromAST(&ctx.Ast, &result) Expect(mapper.Result.ToolCalls).To(HaveLen(1)) Expect(mapper.Result.ToolCalls[0].Name).To(Equal("special_function_with_opt")) }) }) Context("IncrementalParsing", func() { It("handles qwen3 coder format incrementally", func() { tools := createTools() parser := peg.BuildChatPegParser(func(p *peg.ChatBuilder) peg.ParserID { content := p.Rule("content", p.Content(p.Until(""))) var toolParsers []peg.ParserID for _, tool := range tools { var argChoices []peg.ParserID for propName, prop := range tool.Properties { var argValueParser peg.ParserID if prop.Type == "string" { argValueParser = p.ToolArgStringValue(p.UntilOneOf("\n\n")) } else { argValueParser = p.ToolArgJSONValue(p.JSON()) } arg := p.ToolArg(p.Seq( p.ToolArgOpen(p.Literal("")), argValueParser, p.ToolArgClose(p.Seq( p.Literal("\n"), p.Peek(p.Choice(p.Literal(""))), )), )) argChoices = append(argChoices, arg) } argChoice := p.Choice(argChoices...) args := p.ZeroOrMore(argChoice) toolParser := p.Rule("tool-"+tool.Name, p.Seq( p.ToolOpen(p.Seq(p.Literal("\n"))), args, p.ToolClose(p.Literal("")), )) toolParsers = append(toolParsers, toolParser) } toolCall := p.TriggerRule("tool-call", p.Seq( p.Literal(""), p.Space(), p.Choice(toolParsers...), p.Space(), p.Literal(""), )) return p.Seq(content, p.ZeroOrMore(p.Seq(p.Space(), toolCall)), p.End()) }) input := "Let me search the knowledge base for cat pictures." + "\n" + "\n" + "cat pictures\n" + "general\n" + "\n" + "" tokens := simpleTokenize(input) var accumulated string var prevToolCalls int for i, tok := range tokens { accumulated += tok isPartial := i < len(tokens)-1 ctx := peg.NewParseContext(accumulated, isPartial) result := parser.Parse(ctx) Expect(result.Type).NotTo(Equal(peg.Fail), "parse failed at token %d, input: %s", i, accumulated) mapper := &peg.ChatPegMapper{} mapper.FromAST(&ctx.Ast, &result) Expect(len(mapper.Result.ToolCalls)).To(BeNumerically(">=", prevToolCalls), "tool call count decreased at token %d", i) prevToolCalls = len(mapper.Result.ToolCalls) } }) It("handles qwen3 non-coder format incrementally", func() { tools := createTools() parser := peg.BuildChatPegParser(func(p *peg.ChatBuilder) peg.ParserID { toolCall := p.StandardJSONTools(peg.StandardJSONToolsOpts{ SectionStart: "", SectionEnd: "", Tools: tools, ParallelCalls: true, }) return p.Seq( p.Content(p.Until("")), p.Optional(p.Seq(p.Space(), toolCall)), p.End(), ) }) input := "I need to get the weather.\n" + "" + `{"name": "get_current_weather", "arguments": {"location": "New York City, NY", "unit": "fahrenheit"}}` + "" tokens := simpleTokenize(input) var accumulated string for i, tok := range tokens { accumulated += tok isPartial := i < len(tokens)-1 ctx := peg.NewParseContext(accumulated, isPartial) result := parser.Parse(ctx) Expect(result.Type).NotTo(Equal(peg.Fail), "parse failed at token %d, input: %s", i, accumulated) } ctx := peg.NewParseContext(input, false) result := parser.Parse(ctx) Expect(result.Type).To(Equal(peg.Success)) mapper := &peg.ChatPegMapper{} mapper.FromAST(&ctx.Ast, &result) Expect(mapper.Result.ToolCalls).To(HaveLen(1)) Expect(mapper.Result.ToolCalls[0].Name).To(Equal("get_current_weather")) }) }) Context("Command7 complex input", func() { It("parses complex reasoning and tool calls", func() { parser := peg.BuildChatPegParser(func(p *peg.ChatBuilder) peg.ParserID { thinking := p.ReasoningBlock(p.Seq( p.Literal("<|START_THINKING|>"), p.Space(), p.Reasoning(p.Until("<|END_THINKING|>")), p.Space(), p.Literal("<|END_THINKING|>"), )) toolCallID := p.Atomic(p.Seq( p.Literal("\"tool_call_id\""), p.Space(), p.Literal(":"), p.Space(), p.Literal("\""), p.ToolID(p.JSONString()), p.Literal("\""), )) toolCallName := p.Atomic(p.Seq( p.Literal("\"tool_name\""), p.Space(), p.Literal(":"), p.Space(), p.Literal("\""), p.ToolName(p.JSONString()), p.Literal("\""), )) toolCallArgs := p.Seq( p.Literal("\"parameters\""), p.Space(), p.Literal(":"), p.Space(), p.ToolArgs(p.JSON()), ) toolCallFields := p.Rule("tool-call-fields", p.Choice(toolCallID, toolCallName, toolCallArgs)) toolCall := p.Rule("tool-call-single", p.Tool(p.Seq( p.ToolOpen(p.Literal("{")), p.Space(), toolCallFields, p.ZeroOrMore(p.Seq(p.Literal(","), p.Space(), toolCallFields)), p.Space(), p.ToolClose(p.Literal("}")), ))) toolCalls := p.Rule("tool-calls", p.Seq( p.Literal("<|START_ACTION|>"), p.Space(), p.Literal("["), p.Space(), toolCall, p.ZeroOrMore(p.Seq(p.Literal(","), p.Space(), toolCall)), p.Space(), p.Literal("]"), p.Space(), p.Literal("<|END_ACTION|>"), )) return p.Seq( p.Optional(p.Seq(thinking, p.Space())), toolCalls, p.End(), ) }) reasoning := "To plan an effective trip to Japan that includes both historical sites and modern attractions within a " + "budget of $4000 for a two-week stay, we need to:\n\n" + "1. Identify key historical sites and modern attractions in Japan.\n" + "2. Find affordable accommodation options that provide a balance between comfort and cost.\n" + "3. Determine the best modes of transportation for getting around Japan.\n" + "4. Create a day-by-day itinerary that ensures the user gets to see a variety of attractions without " + "overspending.\n" + "5. Provide a detailed cost breakdown that includes accommodation, transportation, meals, and entry fees " + "to attractions." input := "<|START_THINKING|>" + reasoning + "<|END_THINKING|>" + `<|START_ACTION|>[{"tool_call_id": "call_0", "tool_name": "plan_trip", "parameters": {"destination": "Japan", "duration": 14, "budget": 4000, "interests": ["historical sites", "modern attractions"], "accommodation_preferences": "affordable", "transportation_preferences": "efficient", "meal_preferences": "local cuisine"}}]<|END_ACTION|>` ctx := peg.NewParseContext(input, false) result := parser.Parse(ctx) Expect(result.Type).To(Equal(peg.Success)) mapper := &peg.ChatPegMapper{} mapper.FromAST(&ctx.Ast, &result) msg := mapper.Result Expect(msg.ReasoningContent).To(Equal(reasoning)) Expect(msg.ToolCalls).To(HaveLen(1)) Expect(msg.ToolCalls[0].Name).To(Equal("plan_trip")) Expect(msg.ToolCalls[0].ID).To(Equal("call_0")) Expect(msg.ToolCalls[0].Arguments).To(ContainSubstring(`"interests"`)) Expect(msg.ToolCalls[0].Arguments).To(ContainSubstring(`"historical sites"`)) }) }) Context("ForceToolCalls", func() { var parser *peg.Arena BeforeEach(func() { tools := createTools() parser = peg.BuildChatPegParser(func(p *peg.ChatBuilder) peg.ParserID { toolCall := p.StandardJSONTools(peg.StandardJSONToolsOpts{ SectionStart: "[", SectionEnd: "]", Tools: tools, ForceToolCalls: true, }) return p.Seq( p.Content(p.Until("")), p.Space(), toolCall, p.Space(), p.End(), ) }) }) It("succeeds with tool call present", func() { input := "Let me check." + `[{"name": "get_current_weather", "arguments": {"location": "NYC", "unit": "celsius"}}]` ctx := peg.NewParseContext(input, false) result := parser.Parse(ctx) Expect(result.Type).To(Equal(peg.Success)) mapper := &peg.ChatPegMapper{} mapper.FromAST(&ctx.Ast, &result) Expect(mapper.Result.ToolCalls).To(HaveLen(1)) Expect(mapper.Result.ToolCalls[0].Name).To(Equal("get_current_weather")) }) It("fails without tool call", func() { input := "Just a response without any tool calls." ctx := peg.NewParseContext(input, false) result := parser.Parse(ctx) Expect(result.Type).To(Equal(peg.Fail)) }) }) Context("NestedKeysJSONTools", func() { It("parses nested function.name and function.arguments keys", func() { tools := []peg.ToolDef{ { Name: "get_current_weather", Properties: map[string]peg.PropDef{ "location": {Type: "string"}, }, }, } parser := peg.BuildChatPegParser(func(p *peg.ChatBuilder) peg.ParserID { toolCall := p.StandardJSONTools(peg.StandardJSONToolsOpts{ SectionStart: "", SectionEnd: "", Tools: tools, NameKey: "function.name", ArgsKey: "function.arguments", CallIDKey: "id", }) return p.Seq( p.Content(p.Until("")), p.Optional(p.Seq(p.Space(), toolCall)), p.End(), ) }) input := `Let me check.{"id": "call_123", "function": {"name": "get_current_weather", "arguments": {"location": "NYC"}}}` ctx := peg.NewParseContext(input, false) result := parser.Parse(ctx) Expect(result.Type).To(Equal(peg.Success)) mapper := &peg.ChatPegMapper{} mapper.FromAST(&ctx.Ast, &result) msg := mapper.Result Expect(msg.ToolCalls).To(HaveLen(1)) Expect(msg.ToolCalls[0].Name).To(Equal("get_current_weather")) Expect(msg.ToolCalls[0].ID).To(Equal("call_123")) }) }) Context("Command7 incremental", func() { It("handles incremental parsing without regressions", func() { parser := peg.BuildChatPegParser(func(p *peg.ChatBuilder) peg.ParserID { thinking := p.ReasoningBlock(p.Seq( p.Literal("<|START_THINKING|>"), p.Space(), p.Reasoning(p.Until("<|END_THINKING|>")), p.Space(), p.Literal("<|END_THINKING|>"), )) toolCallID := p.Atomic(p.Seq( p.Literal("\"tool_call_id\""), p.Space(), p.Literal(":"), p.Space(), p.Literal("\""), p.ToolID(p.JSONString()), p.Literal("\""), )) toolCallName := p.Atomic(p.Seq( p.Literal("\"tool_name\""), p.Space(), p.Literal(":"), p.Space(), p.Literal("\""), p.ToolName(p.JSONString()), p.Literal("\""), )) toolCallArgs := p.Seq( p.Literal("\"parameters\""), p.Space(), p.Literal(":"), p.Space(), p.ToolArgs(p.JSON()), ) toolCallFields := p.Rule("tool-call-fields", p.Choice(toolCallID, toolCallName, toolCallArgs)) toolCall := p.Rule("tool-call-single", p.Tool(p.Seq( p.ToolOpen(p.Literal("{")), p.Space(), toolCallFields, p.ZeroOrMore(p.Seq(p.Literal(","), p.Space(), toolCallFields)), p.Space(), p.ToolClose(p.Literal("}")), ))) toolCalls := p.Rule("tool-calls", p.Seq( p.Literal("<|START_ACTION|>"), p.Space(), p.Literal("["), p.Space(), toolCall, p.ZeroOrMore(p.Seq(p.Literal(","), p.Space(), toolCall)), p.Space(), p.Literal("]"), p.Space(), p.Literal("<|END_ACTION|>"), )) return p.Seq( p.Optional(p.Seq(thinking, p.Space())), toolCalls, p.End(), ) }) reasoning := "To plan an effective trip to Japan that includes both historical sites and modern attractions within a " + "budget of $4000 for a two-week stay, we need to:\n\n" + "1. Identify key historical sites and modern attractions in Japan.\n" + "2. Find affordable accommodation options.\n" + "3. Determine the best modes of transportation.\n" + "4. Create a day-by-day itinerary.\n" + "5. Provide a detailed cost breakdown." input := "<|START_THINKING|>" + reasoning + "<|END_THINKING|>" + `<|START_ACTION|>[{"tool_call_id": "call_0", "tool_name": "plan_trip", "parameters": {"destination": "Japan", "duration": 14, "budget": 4000, "interests": ["historical sites", "modern attractions"]}}]<|END_ACTION|>` tokens := simpleTokenize(input) var accumulated string var prevToolCalls int for i, tok := range tokens { accumulated += tok isPartial := i < len(tokens)-1 ctx := peg.NewParseContext(accumulated, isPartial) result := parser.Parse(ctx) Expect(result.Type).NotTo(Equal(peg.Fail), "parse failed at token %d, accumulated length=%d", i, len(accumulated)) mapper := &peg.ChatPegMapper{} mapper.FromAST(&ctx.Ast, &result) Expect(len(mapper.Result.ToolCalls)).To(BeNumerically(">=", prevToolCalls), "tool call count decreased at token %d", i) prevToolCalls = len(mapper.Result.ToolCalls) } ctx := peg.NewParseContext(input, false) result := parser.Parse(ctx) Expect(result.Type).To(Equal(peg.Success)) mapper := &peg.ChatPegMapper{} mapper.FromAST(&ctx.Ast, &result) msg := mapper.Result Expect(msg.ReasoningContent).To(Equal(reasoning)) Expect(msg.ToolCalls).To(HaveLen(1)) Expect(msg.ToolCalls[0].Name).To(Equal("plan_trip")) Expect(msg.ToolCalls[0].ID).To(Equal("call_0")) }) }) })