diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 15259df..5a1f442 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -167,7 +167,8 @@ Workspace-bound route는 workspace가 없거나 상대 경로이면 OpenAI-compa `tools`가 있는 Chat Completions 요청에서 Edge는 backend-native tool call stream을 일반 구조화 이벤트로 중계하지 않는다. 단, backend가 assistant content에 Cline-style 텍스트 tool call 블록을 출력하고 그 function name이 요청의 `tools[].function.name`에 포함되어 있으면 Edge는 해당 블록을 OpenAI-compatible `message.tool_calls` 또는 stream `delta.tool_calls`로 변환하고 `finish_reason: "tool_calls"`를 반환한다. -지원하는 텍스트 블록의 최소 형태는 `>>JSON-or-text`이다. +지원하는 텍스트 블록의 최소 형태는 `>>JSON-or-text` 또는 `{{function_name(key=Python/JSON-like-literal)}}`이다. +템플릿 호출 형태의 인자는 single-quoted string, `True`/`False`/`None`, 배열, 객체를 JSON `arguments` 문자열로 정규화한다. 호환성을 위해 `tool_choice`가 생략되었거나 `auto`/강제 tool 선택 형태로 들어오면 내부 실행 입력에서는 `tool_choice: "none"`으로 낮춰 백엔드의 auto tool-calling 요구 조건에 의해 요청 전체가 실패하지 않게 한다. `parallel_tool_calls`와 `stream_options`는 클라이언트 호환성을 위해 수신하지만 현재 Edge 실행 의미에는 반영하지 않는다. diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go index 63b7661..66c770b 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -322,6 +322,101 @@ func TestChatCompletionsSynthesizesToolCallsFromClineTextBlock(t *testing.T) { } } +func TestChatCompletionsSynthesizesToolCallsFromClineTemplateCall(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "사용자가 변경 내용을 푸시해달라고 요청하셨습니다. 먼저 현재 git 상태를 확인하여 변경된 파일과 커밋 가능한 내용을 파악하겠습니다.\n\n{{run_commands(commands=[{'command': 'git status', 'description': '현재 git 상태 확인', 'runInTerminal': True}]})}}"} + fake.events <- &iop.RunEvent{Type: "complete"} + + srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"qwen3.6:35b", + "messages":[{"role":"user","content":"push changes"}], + "tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object"}}}], + "tool_choice":"auto" + }`)) + w := httptest.NewRecorder() + + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + var resp chatCompletionResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + choice := resp.Choices[0] + if choice.FinishReason != "tool_calls" { + t.Fatalf("finish_reason: got %q, want tool_calls", choice.FinishReason) + } + if strings.Contains(choice.Message.Content, "{{run_commands") { + t.Fatalf("content still contains raw template tool call: %q", choice.Message.Content) + } + if len(choice.Message.ToolCalls) != 1 { + t.Fatalf("tool_calls len: got %d", len(choice.Message.ToolCalls)) + } + call, ok := choice.Message.ToolCalls[0].(map[string]any) + if !ok { + t.Fatalf("tool call shape: %+v", choice.Message.ToolCalls[0]) + } + fn, ok := call["function"].(map[string]any) + if !ok { + t.Fatalf("function shape: %+v", call["function"]) + } + if fn["name"] != "run_commands" { + t.Fatalf("function name: got %+v", fn["name"]) + } + var args map[string][]map[string]any + if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil { + t.Fatalf("arguments JSON: %v", err) + } + commands := args["commands"] + if len(commands) != 1 { + t.Fatalf("commands len: got %d", len(commands)) + } + if commands[0]["command"] != "git status" || commands[0]["description"] != "현재 git 상태 확인" || commands[0]["runInTerminal"] != true { + t.Fatalf("commands args: %+v", commands[0]) + } +} + +func TestSynthesizesTemplateToolCallWithClosingMarkerInString(t *testing.T) { + content := "확인합니다.\n\n{{run_commands(commands=[{'command': \"printf ')}}'\", 'description': 'marker test', 'runInTerminal': False}])}}" + tools := []any{map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "run_commands", + }, + }} + + cleaned, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test") + + if cleaned != "확인합니다." { + t.Fatalf("content: got %q", cleaned) + } + if len(toolCalls) != 1 { + t.Fatalf("tool_calls len: got %d", len(toolCalls)) + } + call, ok := toolCalls[0].(map[string]any) + if !ok { + t.Fatalf("tool call shape: %+v", toolCalls[0]) + } + fn, ok := call["function"].(map[string]any) + if !ok { + t.Fatalf("function shape: %+v", call["function"]) + } + var args map[string][]map[string]any + if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil { + t.Fatalf("arguments JSON: %v", err) + } + commands := args["commands"] + if len(commands) != 1 { + t.Fatalf("commands len: got %d", len(commands)) + } + if commands[0]["command"] != "printf ')}}'" || commands[0]["runInTerminal"] != false { + t.Fatalf("commands args: %+v", commands[0]) + } +} + func TestChatCompletionsLeavesUnknownTextToolCallAsContent(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "true"} @@ -352,6 +447,36 @@ func TestChatCompletionsLeavesUnknownTextToolCallAsContent(t *testing.T) { } } +func TestChatCompletionsLeavesUnknownTemplateToolCallAsContent(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "{{delete_everything(confirm=True)}}"} + fake.events <- &iop.RunEvent{Type: "complete"} + + srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"qwen3.6:35b", + "messages":[{"role":"user","content":"status"}], + "tools":[{"type":"function","function":{"name":"run_commands"}}] + }`)) + w := httptest.NewRecorder() + + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + var resp chatCompletionResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Choices[0].FinishReason == "tool_calls" || len(resp.Choices[0].Message.ToolCalls) != 0 { + t.Fatalf("unexpected tool call conversion: %+v", resp.Choices[0]) + } + if !strings.Contains(resp.Choices[0].Message.Content, "{{delete_everything") { + t.Fatalf("raw unknown template tool call should remain content: %q", resp.Choices[0].Message.Content) + } +} + func TestChatCompletionsMapsMaxCompletionTokens(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} @@ -441,6 +566,44 @@ func TestChatCompletionsStreamSynthesizesToolCallsFromClineTextBlock(t *testing. } } +func TestChatCompletionsStreamSynthesizesToolCallsFromClineTemplateCall(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "먼저 현재 git 상태를 확인하겠습니다.\n\n"} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "{{run_commands(commands=[{'command': 'git status', 'description': '현재 git 상태 확인', 'runInTerminal': True}]})}}"} + fake.events <- &iop.RunEvent{Type: "complete"} + + srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"stream-model", + "stream":true, + "messages":[{"role":"user","content":"status"}], + "tools":[{"type":"function","function":{"name":"run_commands"}}] + }`)) + w := httptest.NewRecorder() + + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + body := w.Body.String() + if !strings.Contains(body, `"tool_calls"`) || !strings.Contains(body, `"finish_reason":"tool_calls"`) { + t.Fatalf("stream did not synthesize tool_calls:\n%s", body) + } + if strings.Contains(body, "{{run_commands") { + t.Fatalf("stream leaked raw template tool call:\n%s", body) + } + if !strings.Contains(body, `"index":0`) { + t.Fatalf("stream tool_call delta should include index:\n%s", body) + } + if !strings.Contains(body, `\"runInTerminal\":true`) { + t.Fatalf("stream tool_call arguments should include parsed boolean:\n%s", body) + } + if !strings.Contains(body, `"content":"먼저 현재 git 상태를 확인하겠습니다."`) { + t.Fatalf("stream should preserve text before tool call:\n%s", body) + } +} + func TestChatCompletionsStreamingReportsClosedRunStream(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent)} close(fake.events) diff --git a/apps/edge/internal/openai/types.go b/apps/edge/internal/openai/types.go index 8bbc067..ab2efe9 100644 --- a/apps/edge/internal/openai/types.go +++ b/apps/edge/internal/openai/types.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "regexp" + "sort" + "strconv" "strings" ) @@ -173,18 +175,33 @@ func contentToString(v any) string { } var ( - textToolCallBlockRE = regexp.MustCompile(`(?is)<\s*tool_call\s*>(.*?)<\s*/\s*tool_call\s*>`) - textToolFunctionRE = regexp.MustCompile(`(?is)<\s*function\s*=\s*([A-Za-z_][A-Za-z0-9_.:-]*)\s*>(.*?)<\s*/\s*function(?:\s*=\s*[A-Za-z_][A-Za-z0-9_.:-]*)?\s*>`) - textToolParameterRE = regexp.MustCompile(`(?is)<\s*parameter\s*=\s*([A-Za-z_][A-Za-z0-9_.:-]*)\s*>(.*?)<\s*/\s*parameter(?:\s*=\s*[A-Za-z_][A-Za-z0-9_.:-]*)?\s*>`) - textToolCallOpeningHint = "(.*?)<\s*/\s*tool_call\s*>`) + textToolFunctionRE = regexp.MustCompile(`(?is)<\s*function\s*=\s*([A-Za-z_][A-Za-z0-9_.:-]*)\s*>(.*?)<\s*/\s*function(?:\s*=\s*[A-Za-z_][A-Za-z0-9_.:-]*)?\s*>`) + textToolParameterRE = regexp.MustCompile(`(?is)<\s*parameter\s*=\s*([A-Za-z_][A-Za-z0-9_.:-]*)\s*>(.*?)<\s*/\s*parameter(?:\s*=\s*[A-Za-z_][A-Za-z0-9_.:-]*)?\s*>`) + textToolCallOpeningHint = " matches[j].end + } + return matches[i].start < matches[j].start + }) + return matches +} + +func collectXMLTextToolCallMatches(content string, allowed map[string]struct{}) []textToolCallMatch { + if !strings.Contains(strings.ToLower(content), textToolCallOpeningHint) { + return nil + } + rawMatches := textToolCallBlockRE.FindAllStringSubmatchIndex(content, -1) + if len(rawMatches) == 0 { + return nil + } + out := make([]textToolCallMatch, 0, len(rawMatches)) + for _, rawMatch := range rawMatches { + block := content[rawMatch[0]:rawMatch[1]] + name, arguments, ok := parseTextToolCallBlock(block, allowed) + if !ok { + continue + } + out = append(out, textToolCallMatch{ + start: rawMatch[0], + end: rawMatch[1], + name: name, + arguments: arguments, + }) + } + return out +} + +func collectMustacheTextToolCallMatches(content string, allowed map[string]struct{}) []textToolCallMatch { + if !strings.Contains(content, textMustacheToolCallOpenHint) { + return nil + } + blocks := scanMustacheTextToolCallBlocks(content) + if len(blocks) == 0 { + return nil + } + out := make([]textToolCallMatch, 0, len(blocks)) + for _, block := range blocks { + if _, ok := allowed[block.name]; !ok { + continue + } + args, ok := parseMustacheToolCallArguments(block.args) + if !ok { + continue + } + encoded, err := json.Marshal(args) + if err != nil { + continue + } + out = append(out, textToolCallMatch{ + start: block.start, + end: block.end, + name: block.name, + arguments: string(encoded), + }) + } + return out +} + +func scanMustacheTextToolCallBlocks(content string) []mustacheTextToolCallBlock { + var blocks []mustacheTextToolCallBlock + for start := 0; start < len(content); { + open := strings.Index(content[start:], textMustacheToolCallOpenHint) + if open < 0 { + break + } + blockStart := start + open + nameStart := skipASCIISpaces(content, blockStart+len(textMustacheToolCallOpenHint)) + nameEnd := nameStart + for nameEnd < len(content) && isTextToolIdentifierChar(content[nameEnd], nameEnd == nameStart) { + nameEnd++ + } + name := content[nameStart:nameEnd] + if name == "" { + start = blockStart + len(textMustacheToolCallOpenHint) + continue + } + callOpen := skipASCIISpaces(content, nameEnd) + if callOpen >= len(content) || content[callOpen] != '(' { + start = blockStart + len(textMustacheToolCallOpenHint) + continue + } + argsStart := callOpen + 1 + argsEnd, blockEnd, ok := findMustacheToolCallEnd(content, argsStart) + if !ok { + start = blockStart + len(textMustacheToolCallOpenHint) + continue + } + blocks = append(blocks, mustacheTextToolCallBlock{ + start: blockStart, + end: blockEnd, + name: name, + args: content[argsStart:argsEnd], + }) + start = blockEnd + } + return blocks +} + +func findMustacheToolCallEnd(content string, argsStart int) (int, int, bool) { + depth := 0 + var quote byte + escaped := false + for i := argsStart; i < len(content); i++ { + c := content[i] + if quote != 0 { + if escaped { + escaped = false + continue + } + if c == '\\' { + escaped = true + continue + } + if c == quote { + quote = 0 + } + continue + } + switch c { + case '\'', '"': + quote = c + case '[', '{', '(': + depth++ + case ']', '}': + if depth > 0 { + depth-- + } + case ')': + if depth > 0 { + depth-- + continue + } + closeStart := skipASCIISpaces(content, i+1) + if closeStart+1 < len(content) && content[closeStart] == '}' && content[closeStart+1] == '}' { + return i, closeStart + 2, true + } + } + } + return 0, 0, false +} + func textToolCallID(runID string, ordinal int) string { normalized := normalizeToolCallIDPart(runID) if normalized == "" { @@ -319,6 +486,424 @@ func parseTextToolParameterValue(raw string) any { return trimmed } +func parseMustacheToolCallArguments(raw string) (map[string]any, bool) { + if args, ok := parseMustacheToolCallArgumentsStrict(raw); ok { + return args, true + } + // Some Cline/provider outputs include one extra "}" before the closing + // template parens, e.g. `...True}]})}}`. Recover only after strict parsing. + trimmed := strings.TrimSpace(raw) + if strings.HasSuffix(trimmed, "}") { + return parseMustacheToolCallArgumentsStrict(strings.TrimSpace(trimmed[:len(trimmed)-1])) + } + return nil, false +} + +func parseMustacheToolCallArgumentsStrict(raw string) (map[string]any, bool) { + trimmed := strings.TrimSpace(raw) + args := map[string]any{} + if trimmed == "" { + return args, true + } + for _, part := range splitTopLevel(trimmed, ',') { + part = strings.TrimSpace(part) + if part == "" { + continue + } + eq := indexTopLevel(part, '=') + if eq < 0 { + return nil, false + } + key := strings.TrimSpace(part[:eq]) + if !isTextToolIdentifier(key) { + return nil, false + } + value, ok := parseTextToolLiteral(part[eq+1:]) + if !ok { + return nil, false + } + args[key] = value + } + return args, true +} + +func parseTextToolLiteral(raw string) (any, bool) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", true + } + var decoded any + if err := json.Unmarshal([]byte(trimmed), &decoded); err == nil { + return decoded, true + } + parser := pythonishLiteralParser{s: trimmed} + value, ok := parser.parseValue() + if !ok { + return nil, false + } + parser.skipSpaces() + if parser.pos != len(parser.s) { + return nil, false + } + return value, true +} + +func splitTopLevel(s string, sep byte) []string { + parts := []string{} + start := 0 + depth := 0 + var quote byte + escaped := false + for i := 0; i < len(s); i++ { + c := s[i] + if quote != 0 { + if escaped { + escaped = false + continue + } + if c == '\\' { + escaped = true + continue + } + if c == quote { + quote = 0 + } + continue + } + switch c { + case '\'', '"': + quote = c + case '[', '{', '(': + depth++ + case ']', '}', ')': + if depth > 0 { + depth-- + } + case sep: + if depth == 0 { + parts = append(parts, s[start:i]) + start = i + 1 + } + } + } + parts = append(parts, s[start:]) + return parts +} + +func indexTopLevel(s string, target byte) int { + depth := 0 + var quote byte + escaped := false + for i := 0; i < len(s); i++ { + c := s[i] + if quote != 0 { + if escaped { + escaped = false + continue + } + if c == '\\' { + escaped = true + continue + } + if c == quote { + quote = 0 + } + continue + } + switch c { + case '\'', '"': + quote = c + case '[', '{', '(': + depth++ + case ']', '}', ')': + if depth > 0 { + depth-- + } + default: + if c == target && depth == 0 { + return i + } + } + } + return -1 +} + +func skipASCIISpaces(s string, pos int) int { + for pos < len(s) { + switch s[pos] { + case ' ', '\n', '\r', '\t': + pos++ + default: + return pos + } + } + return pos +} + +type pythonishLiteralParser struct { + s string + pos int +} + +func (p *pythonishLiteralParser) parseValue() (any, bool) { + p.skipSpaces() + if p.pos >= len(p.s) { + return nil, false + } + switch p.s[p.pos] { + case '\'', '"': + return p.parseString() + case '[': + return p.parseArray() + case '{': + return p.parseObject() + } + if p.consumeWord("True") || p.consumeWord("true") { + return true, true + } + if p.consumeWord("False") || p.consumeWord("false") { + return false, true + } + if p.consumeWord("None") || p.consumeWord("null") { + return nil, true + } + return p.parseNumberOrBare() +} + +func (p *pythonishLiteralParser) parseString() (string, bool) { + if p.pos >= len(p.s) { + return "", false + } + quote := p.s[p.pos] + p.pos++ + var b strings.Builder + for p.pos < len(p.s) { + c := p.s[p.pos] + p.pos++ + if c == quote { + return b.String(), true + } + if c != '\\' { + b.WriteByte(c) + continue + } + if p.pos >= len(p.s) { + return "", false + } + esc := p.s[p.pos] + p.pos++ + switch esc { + case '\\', '\'', '"': + b.WriteByte(esc) + case 'n': + b.WriteByte('\n') + case 'r': + b.WriteByte('\r') + case 't': + b.WriteByte('\t') + case 'b': + b.WriteByte('\b') + case 'f': + b.WriteByte('\f') + case 'u': + if p.pos+4 > len(p.s) { + return "", false + } + r, err := strconv.ParseInt(p.s[p.pos:p.pos+4], 16, 32) + if err != nil { + return "", false + } + b.WriteRune(rune(r)) + p.pos += 4 + default: + b.WriteByte(esc) + } + } + return "", false +} + +func (p *pythonishLiteralParser) parseArray() ([]any, bool) { + p.pos++ + items := []any{} + p.skipSpaces() + if p.consumeByte(']') { + return items, true + } + for { + value, ok := p.parseValue() + if !ok { + return nil, false + } + items = append(items, value) + p.skipSpaces() + if p.consumeByte(']') { + return items, true + } + if !p.consumeByte(',') { + return nil, false + } + p.skipSpaces() + if p.consumeByte(']') { + return items, true + } + } +} + +func (p *pythonishLiteralParser) parseObject() (map[string]any, bool) { + p.pos++ + obj := map[string]any{} + p.skipSpaces() + if p.consumeByte('}') { + return obj, true + } + for { + key, ok := p.parseObjectKey() + if !ok { + return nil, false + } + p.skipSpaces() + if !p.consumeByte(':') { + return nil, false + } + value, ok := p.parseValue() + if !ok { + return nil, false + } + obj[key] = value + p.skipSpaces() + if p.consumeByte('}') { + return obj, true + } + if !p.consumeByte(',') { + return nil, false + } + p.skipSpaces() + if p.consumeByte('}') { + return obj, true + } + } +} + +func (p *pythonishLiteralParser) parseObjectKey() (string, bool) { + p.skipSpaces() + if p.pos >= len(p.s) { + return "", false + } + if p.s[p.pos] == '\'' || p.s[p.pos] == '"' { + return p.parseString() + } + start := p.pos + for p.pos < len(p.s) && isTextToolIdentifierChar(p.s[p.pos], p.pos == start) { + p.pos++ + } + if start == p.pos { + return "", false + } + return p.s[start:p.pos], true +} + +func (p *pythonishLiteralParser) parseNumberOrBare() (any, bool) { + start := p.pos + for p.pos < len(p.s) && !isLiteralDelimiter(p.s[p.pos]) { + p.pos++ + } + if start == p.pos { + return nil, false + } + token := p.s[start:p.pos] + if value, ok := parseNumberToken(token); ok { + return value, true + } + if isTextToolIdentifier(token) { + return token, true + } + return nil, false +} + +func (p *pythonishLiteralParser) skipSpaces() { + for p.pos < len(p.s) { + switch p.s[p.pos] { + case ' ', '\n', '\r', '\t': + p.pos++ + default: + return + } + } +} + +func (p *pythonishLiteralParser) consumeByte(b byte) bool { + if p.pos < len(p.s) && p.s[p.pos] == b { + p.pos++ + return true + } + return false +} + +func (p *pythonishLiteralParser) consumeWord(word string) bool { + if !strings.HasPrefix(p.s[p.pos:], word) { + return false + } + end := p.pos + len(word) + if end < len(p.s) && isBareLiteralChar(p.s[end]) { + return false + } + p.pos = end + return true +} + +func parseNumberToken(token string) (any, bool) { + if token == "" { + return nil, false + } + if strings.ContainsAny(token, ".eE") { + value, err := strconv.ParseFloat(token, 64) + if err != nil { + return nil, false + } + return value, true + } + value, err := strconv.ParseInt(token, 10, 64) + if err != nil { + return nil, false + } + return value, true +} + +func isTextToolIdentifier(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + if !isTextToolIdentifierChar(s[i], i == 0) { + return false + } + } + return true +} + +func isTextToolIdentifierChar(c byte, first bool) bool { + if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '_' { + return true + } + if !first && (c >= '0' && c <= '9' || c == '.' || c == ':' || c == '-') { + return true + } + return false +} + +func isBareLiteralChar(c byte) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' +} + +func isLiteralDelimiter(c byte) bool { + switch c { + case ',', ']', '}', ' ', '\n', '\r', '\t': + return true + default: + return false + } +} + func previewString(s string, max int) string { if max <= 0 || len(s) <= max { return s