internal/opencode: runner (idle/hard timeout, process-group kill, resume-fallback) + verdict/json parsing
All checks were successful
build-test / build (push) Successful in 1m21s
All checks were successful
build-test / build (push) Successful in 1m21s
This commit is contained in:
86
internal/opencode/extract.go
Normal file
86
internal/opencode/extract.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Package opencode — запуск opencode-субагентов и разбор их вердиктов.
|
||||
//
|
||||
// Единственный компонент, умеющий запускать opencode run и парсить вывод.
|
||||
// Контракты перенесены 1-в-1 из Python-версии (extract.py / opencode.py):
|
||||
// - ExtractVerdict: последний text-парт из NDJSON-потока opencode run --format json
|
||||
// - ExtractJSON: fenced ```json``` → первый {...}
|
||||
// - Run/ResumeDev: запуск процесса с idle/hard timeout по opencode.db
|
||||
package opencode
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
fenceRe = regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```")
|
||||
jsonBlockRe = regexp.MustCompile("\\{[\\s\\S]*\\}")
|
||||
sessionRe = regexp.MustCompile(`"session_id"\s*:\s*"([^"]+)"`)
|
||||
)
|
||||
|
||||
// ExtractVerdict возвращает текст вердикта из NDJSON-потока opencode run --format json.
|
||||
// Поток — NDJSON: последний парт {"type":"text","part":{"text":...}} и есть вердикт.
|
||||
// Если JSON-партов нет (например, простой текст) — возвращает исходную строку.
|
||||
func ExtractVerdict(out string) string {
|
||||
out = strings.TrimSpace(out)
|
||||
if out == "" {
|
||||
return ""
|
||||
}
|
||||
var last string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "{") {
|
||||
continue
|
||||
}
|
||||
var obj struct {
|
||||
Type string `json:"type"`
|
||||
Part struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"part"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &obj); err != nil {
|
||||
continue
|
||||
}
|
||||
if obj.Type == "text" && obj.Part.Text != "" {
|
||||
last = obj.Part.Text
|
||||
}
|
||||
}
|
||||
if last != "" {
|
||||
return strings.TrimSpace(last)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ExtractJSON извлекает JSON из ответа модели: сначала fenced ```json```,
|
||||
// затем первый {...}. Возвращает (nil, false), если JSON нет или невалиден.
|
||||
// Это НЕ ошибка — вызывающий решает, как деградировать (класс O3 ErrParse).
|
||||
func ExtractJSON(text string) (map[string]json.RawMessage, bool) {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil, false
|
||||
}
|
||||
for _, f := range fenceRe.FindAllStringSubmatch(text, -1) {
|
||||
if len(f) > 1 {
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(f[1])), &obj); err == nil && obj != nil {
|
||||
return obj, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if m := jsonBlockRe.FindString(text); m != "" {
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(m), &obj); err == nil && obj != nil {
|
||||
return obj, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// SessionIDFromOutput извлекает session_id из текстового вывода opencode.
|
||||
func SessionIDFromOutput(out string) (string, bool) {
|
||||
m := sessionRe.FindStringSubmatch(out)
|
||||
if len(m) > 1 && m[1] != "" {
|
||||
return m[1], true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
Reference in New Issue
Block a user