Files
ratatoskr-go/internal/opencode/extract.go
Hermes 733e63339a
Some checks failed
CI / test (push) Successful in 40s
CI / build-and-package (amd64, linux) (push) Successful in 35s
CI / build-and-package (amd64, windows) (push) Failing after 26s
feat: opencode через HTTP API — пул serve-серверов вместо spawn/NDJSON
Runner теперь ходит к постоянным serve по HTTP API (v1.17+, /api):
- клиент Client (create/send/wait/abort/messages/verdict)
- Pool: по одному serve на каталог, ленивый подъём, root-сервер в worktree,
  выделение портов, ReleaseTask при завершении задачи
- Run: CreateSession('ratatoskr-<агент>') -> Send -> поллинг Verdict из
  text-частей assistant-сообщений; idle/hard таймауты дают RC=-1
- вердикт извлекается из последнего assistant text-парта (плоский text)
- тесты: unit на фейковом HTTP-сервере; e2e эмулирует serve через httptest,
  агент определяется по title сессии
2026-08-18 13:36:54 +05:00

96 lines
3.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}
// stripFence обрезает внешние ```json``` (или ```) ограждения вокруг фрагмента.
// Используется для вердиктов, которые модель может вернуть в markdown-фенсе.
func stripFence(s string) string {
s = strings.TrimSpace(s)
if f := fenceRe.FindStringSubmatch(s); len(f) > 1 {
s = strings.TrimSpace(f[1])
}
return strings.TrimSpace(s)
}