Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

170 lines
4.3 KiB
Go

// Package llm — text helpers shared by the subprocess CLI providers.
//
// The `claudecli` and `codex` providers both shell out to an external
// coding-agent binary that has no native structured-output mechanism.
// They get the expand / rerank / verify / tool-call JSON shapes the
// same way: append a JSON-Schema instruction to the prompt, then
// extract the first balanced JSON value out of a possibly chatty
// response. That logic is provider-neutral, so it lives here once
// rather than being copied into each provider package.
package llm
import (
"encoding/json"
"strings"
"unicode/utf8"
)
// AppendSchemaInstruction tacks a "respond with exactly this JSON
// shape" rider onto a system / instruction prompt. The CLI providers
// have no native structured-output flag, so this — paired with
// ExtractJSON on the response — is how the structured shapes are
// enforced. Returns the prompt unchanged for ShapeFreeform (or if the
// schema fails to marshal, which the hand-built schemas never do).
func AppendSchemaInstruction(prompt string, shape JSONShape, tools []ToolSpec) string {
schema := JSONSchemaFor(shape, tools)
if schema == nil {
return prompt
}
raw, err := json.Marshal(schema)
if err != nil {
return prompt
}
rider := "Respond with a single JSON object that conforms exactly to this JSON Schema:\n" +
string(raw) +
"\nOutput ONLY the JSON object — no prose, no commentary, no markdown fences."
if strings.TrimSpace(prompt) == "" {
return rider
}
return prompt + "\n\n" + rider
}
// ExtractJSON pulls the first balanced JSON object or array out of
// text. CLI agents sometimes wrap responses in markdown fences or
// surround them with prose ("Sure, here you go:\n{...}"); this helper
// finds and verifies the JSON payload so the assist passes don't choke
// on chatty completions. ok is false when no valid JSON value is
// present.
func ExtractJSON(text string) (string, bool) {
if c, ok := tryUnmarshalJSON(text); ok {
return c, true
}
if stripped, changed := stripJSONFences(text); changed {
if c, ok := tryUnmarshalJSON(stripped); ok {
return c, true
}
text = stripped
}
// Scan for the first balanced JSON object/array.
for i := 0; i < len(text); i++ {
c := text[i]
if c != '{' && c != '[' {
continue
}
end, ok := balancedJSONEnd(text, i)
if !ok {
continue
}
candidate := text[i : end+1]
if c, ok := tryUnmarshalJSON(candidate); ok {
return c, true
}
}
return "", false
}
// Snippet truncates a stderr / stdout blob for inclusion in an error
// message. Operates on runes so multi-byte characters at the cut point
// stay intact.
func Snippet(b []byte) string {
const max = 300
s := strings.TrimSpace(string(b))
if utf8.RuneCountInString(s) <= max {
return s
}
count := 0
for i := range s {
count++
if count > max {
return s[:i] + "…"
}
}
return s
}
// stripJSONFences removes a single ``` or ```json wrapper.
func stripJSONFences(text string) (string, bool) {
t := strings.TrimSpace(text)
if !strings.HasPrefix(t, "```") {
return text, false
}
_, body, ok := strings.Cut(t, "\n")
if !ok {
return text, false
}
if i := strings.LastIndex(body, "```"); i >= 0 {
body = body[:i]
}
return strings.TrimSpace(body), true
}
// tryUnmarshalJSON returns the trimmed candidate if it parses as JSON.
func tryUnmarshalJSON(s string) (string, bool) {
trimmed := strings.TrimSpace(s)
if trimmed == "" {
return "", false
}
var v any
if err := json.Unmarshal([]byte(trimmed), &v); err != nil {
return "", false
}
return trimmed, true
}
// balancedJSONEnd returns the index of the closing brace/bracket that
// balances the opener at start. Tracks string literals so quoted
// braces don't throw the depth count off.
func balancedJSONEnd(text string, start int) (int, bool) {
open := text[start]
var closer byte
switch open {
case '{':
closer = '}'
case '[':
closer = ']'
default:
return 0, false
}
depth := 0
inString := false
escape := false
for i := start; i < len(text); i++ {
c := text[i]
if inString {
if escape {
escape = false
continue
}
switch c {
case '\\':
escape = true
case '"':
inString = false
}
continue
}
switch c {
case '"':
inString = true
case open:
depth++
case closer:
depth--
if depth == 0 {
return i, true
}
}
}
return 0, false
}