fix(opencode): агенты — в <worktree>/.opencode/agent, OPENCODE_CONFIG_DIR больше не выставляем
OPENCODE_CONFIG_DIR в opencode v1.18.18 перенаправляет Global.Path.config (global.ts: config = OPENCODE_CONFIG_DIR ?? ~/.config/opencode), из-за чего глобальный конфиг (модель/провайдеры, напр. tokentool) не загружался и opencode уходил в fallback-модель. Агенты открывались, т.к. OPENCODE_CONFIG_DIR дополнительно сканируется как каталог для agent/*.md. Теперь агенты распаковываются в <worktree>/.opencode/agent/*.md, где opencode находит их через project-каталог .opencode (paths.ts, cwd=worktree). Глобальный конфиг не трогаем вовсе. - internal/opencode: удалены Server.ConfigDir/Pool.ConfigDir и env OPENCODE_CONFIG_DIR - internal/config: удалено поле OpenCodeCfg.ConfigDir (config_dir) - internal/app: ensureAgentsDir пишет в <worktree>/.opencode/agent - internal/agents: WriteTo(dir) → dir/agent/*.md - README/config.yaml.example/memory обновлены
This commit is contained in:
@@ -46,7 +46,7 @@ docs/ ui-spec.md — спека Fyne UI (слои, event-bus, fyne.Do
|
||||
|
||||
- Интеграция с субагентами — через headless `opencode serve`, **v2 API** (`/api/*`). Версия opencode >= 1.18.18.
|
||||
- **Модель — только глобальный конфиг opencode.** Ratatoskr модель не выбирает и про неё не знает: opencode сам берёт модель по умолчанию из своего глобального конфига. Код opencode-конфиг не читает (config.go удалён).
|
||||
- **Свой агент:** при создании сессии в `POST /api/session` передаётся `agent` (analyst/dev/reviewer/chat/postmortem) из встроенных определений (`internal/agents`, распаковка в `OPENCODE_CONFIG_DIR`).
|
||||
- **Свой агент:** при создании сессии в `POST /api/session` передаётся `agent` (analyst/dev/reviewer/chat/postmortem) из встроенных определений. Агенты распаковываются в `<worktree>/.opencode/agent/*.md` (opencode находит через project-каталог `.opencode`); `OPENCODE_CONFIG_DIR` НЕ выставлять — он перенаправляет Global.Path.config и ломает глобальный конфиг (модель/провайдеры) → падение в fallback.
|
||||
- **Поллинг вердикта:** `POST /api/session/:id/prompt` (durable admit) → `GET /api/session/:id/message?order=desc&limit=200` (новые assistant-сообщения, текст в `content[].type=="text"`) → завершение = `GET /api/session/active` без сессии + финальное assistant-сообщение, стабильное `settlePolls=2` опроса. `POST .../interrupt` вместо abort.
|
||||
|
||||
## Контракты (не ломать)
|
||||
|
||||
@@ -100,7 +100,6 @@ telegram:
|
||||
chat_id: "..." # TG_CHAT_ID (обязательно, если enabled)
|
||||
opencode:
|
||||
bin: "opencode"
|
||||
config_dir: "./agents" # каталог, куда распаковываются встроенные агенты
|
||||
paths:
|
||||
db: "./ratatoskr.db" # дефолт; резолвится от каталога бинаря (не от cwd)
|
||||
worktree: "./worktrees" # то же правило «всё рядом с .exe»
|
||||
@@ -144,8 +143,10 @@ update:
|
||||
глобального конфига (`~/.config/opencode/opencode.jsonc`). Наш код конфиг
|
||||
opencode не читает.
|
||||
- **Свой агент.** При создании сессии в `POST /api/session` передаётся имя
|
||||
встроенного агента ratatoskr (analyst/dev/reviewer/chat/postmortem), который
|
||||
распаковывается в каталог `OPENCODE_CONFIG_DIR` (см. `internal/agents`).
|
||||
встроенного агента ratatoskr (analyst/dev/reviewer/chat/postmortem). Агенты
|
||||
распаковываются в `<worktree>/.opencode/agent/*.md`, где opencode находит
|
||||
их через project-каталог `.opencode` (см. `internal/agents`); глобальный
|
||||
конфиг при этом не трогается.
|
||||
- **Поллинг вердикта.** Промпт отправляется неблокирующе (`POST .../prompt` →
|
||||
durable admit), вердикт собирается из новых assistant-сообщений
|
||||
(`GET .../message`); завершение ответа — сессия ушла из активных дренажей
|
||||
|
||||
@@ -10,10 +10,12 @@ telegram:
|
||||
# Всё ниже — опционально, показаны дефолты:
|
||||
# opencode:
|
||||
# bin: "opencode"
|
||||
# config: "/путь/к/opencode.json" # файл-конфиг модели (OPENCODE_CONFIG)
|
||||
# config_dir: "./agents" # каталог с агентами (OPENCODE_CONFIG_DIR)
|
||||
# config: "/путь/к/opencode.json" # файл-конфиг модели (OPENCODE_CONFIG); пусто = глобальный конфиг opencode
|
||||
# hard_timeout: "20m"
|
||||
# idle_timeout: "5m"
|
||||
#
|
||||
# Агенты (analyst/dev/reviewer/...) распаковываются автоматически в
|
||||
# <worktree>/.opencode/agent/*.md; конфиг opencode для этого не нужен.
|
||||
|
||||
# git (источник репозиториев для git clone)
|
||||
# base_url: "http://gitea.hal9000.home" # базовый URL git-хоста (обязательно для клонирования)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
// Package agents — встроенные агенты opencode (analyst, dev, ...).
|
||||
//
|
||||
// Агенты зашиты в бинарь через go:embed и распаковываются при запуске
|
||||
// в каталог OPENCODE_CONFIG_DIR (по умолчанию ./agents рядом с бинарём),
|
||||
// чтобы opencode находил их независимо от того, где запущен ratatoskr.
|
||||
//
|
||||
// opencode сканирует OPENCODE_CONFIG_DIR как каталог .opencode: агенты
|
||||
// ищутся в поддиректории agents/ (мн.ч.), а не в корне. Поэтому распаковка
|
||||
// идёт в <dir>/agents/*.md.
|
||||
// в project-каталог <worktree>/.opencode/agent/*.md. opencode при serve
|
||||
// (cwd = worktree) обнаруживает .opencode поднятием вверх (ConfigPaths) и
|
||||
// находит агентов по шаблону {agent,agents}/**/*.md. Глобальный конфиг
|
||||
// opencode остаётся нетронутым (OPENCODE_CONFIG_DIR не выставляем).
|
||||
package agents
|
||||
|
||||
import (
|
||||
@@ -23,10 +21,10 @@ var FS embed.FS
|
||||
// в FS, но для детерминированной распаковки список лучше дополнять здесь.
|
||||
var Names = []string{"analyst", "dev", "reviewer", "chat", "postmortem"}
|
||||
|
||||
// WriteTo распаковывает всех встроенных агентов в каталог dir/agents
|
||||
// WriteTo распаковывает всех встроенных агентов в каталог dir/agent
|
||||
// (создаёт его). Файлы перезаписываются — встроенная копия всегда актуальна.
|
||||
func WriteTo(dir string) error {
|
||||
agentsDir := filepath.Join(dir, "agents")
|
||||
agentsDir := filepath.Join(dir, "agent")
|
||||
for _, name := range Names {
|
||||
data, err := FS.ReadFile(name + ".md")
|
||||
if err != nil {
|
||||
|
||||
@@ -13,8 +13,8 @@ func TestWriteTo(t *testing.T) {
|
||||
t.Fatalf("WriteTo: %v", err)
|
||||
}
|
||||
for _, name := range Names {
|
||||
// opencode ищет агентов в поддиректории agents/, как у .opencode
|
||||
p := filepath.Join(dir, "agents", name+".md")
|
||||
// opencode ищет агентов в поддиректории agent/ (или agents/) внутри .opencode
|
||||
p := filepath.Join(dir, "agent", name+".md")
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", p, err)
|
||||
|
||||
@@ -157,7 +157,6 @@ func New(configPath, version, updateToken string, noUI bool) (*App, error) {
|
||||
ocPool := opencode.NewPool(cfg.Paths.Worktree)
|
||||
ocPool.Bin = cfg.OpenCode.Bin
|
||||
ocPool.Config = cfg.OpenCode.Config
|
||||
ocPool.ConfigDir = cfg.OpenCode.ConfigDir
|
||||
ocPool.DBPath = cfg.OpenCode.DBPath
|
||||
ocPool.Host = cfg.OpenCode.Serve.Hostname
|
||||
ocPool.BasePort = cfg.OpenCode.Serve.Port
|
||||
@@ -665,23 +664,21 @@ func (a *App) handleUpdateCmd(ctx context.Context, uid chat.UserID) {
|
||||
// сюда не возвращаемся — SwapAndRestart завершил процесс (os.Exit)
|
||||
}
|
||||
|
||||
// ensureAgentsDir определяет каталог с агентами opencode и распаковывает
|
||||
// туда встроенных агентов (go:embed). Если config_dir не задан — использует
|
||||
// ./agents рядом с бинарём. Встроенные агенты перезаписываются (всегда актуальны).
|
||||
// ensureAgentsDir распаковывает встроенных агентов opencode (go:embed) в
|
||||
// project-каталог <worktree>/.opencode/agent/*.md. opencode при serve (cwd =
|
||||
// worktree) обнаруживает .opencode поднятием вверх (paths.ts) и сам находит
|
||||
// агентов. Глобальный конфиг opencode (модель/провайдеры) не трогаем и
|
||||
// OPENCODE_CONFIG_DIR не выставляем.
|
||||
//
|
||||
// idempotent: вызывается только из New.
|
||||
func ensureAgentsDir(cfg *config.Config) error {
|
||||
if cfg.OpenCode.ConfigDir == "" {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve executable: %w", err)
|
||||
if cfg.Paths.Worktree == "" {
|
||||
return fmt.Errorf("paths.worktree не задан — некуда раскладывать агентов")
|
||||
}
|
||||
// ./agents рядом с бинарём
|
||||
cfg.OpenCode.ConfigDir = filepath.Join(filepath.Dir(exe), "agents")
|
||||
base := filepath.Join(cfg.Paths.Worktree, ".opencode")
|
||||
if err := agents.WriteTo(base); err != nil {
|
||||
return fmt.Errorf("write agents to %s: %w", base, err)
|
||||
}
|
||||
if err := agents.WriteTo(cfg.OpenCode.ConfigDir); err != nil {
|
||||
return fmt.Errorf("write agents to %s: %w", cfg.OpenCode.ConfigDir, err)
|
||||
}
|
||||
log.Printf("app: agents ensured in %s", cfg.OpenCode.ConfigDir)
|
||||
log.Printf("app: agents ensured in %s", base)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -146,34 +146,6 @@ log:
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_OpenCodeConfigDir(t *testing.T) {
|
||||
t.Setenv("TG_TOKEN", "tok")
|
||||
t.Setenv("TG_CHAT_ID", "42")
|
||||
|
||||
// без config_dir — пусто
|
||||
yaml := `telegram:
|
||||
token: "${TG_TOKEN}"
|
||||
chat_id: "${TG_CHAT_ID}"
|
||||
`
|
||||
cfg, err := Load(writeCfg(t, yaml))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.OpenCode.ConfigDir != "" {
|
||||
t.Errorf("config_dir = %q, want пусто", cfg.OpenCode.ConfigDir)
|
||||
}
|
||||
|
||||
// через env OPENCODE_CONFIG_DIR
|
||||
t.Setenv("OPENCODE_CONFIG_DIR", "/opt/agents")
|
||||
cfg, err = Load(writeCfg(t, yaml))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.OpenCode.ConfigDir != "/opt/agents" {
|
||||
t.Errorf("config_dir = %q, want /opt/agents", cfg.OpenCode.ConfigDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_EnvOverride(t *testing.T) {
|
||||
t.Setenv("TG_TOKEN", "tok")
|
||||
t.Setenv("TG_CHAT_ID", "42")
|
||||
|
||||
@@ -89,7 +89,6 @@ type OpenCodeCfg struct {
|
||||
Bin string `yaml:"bin" default:"opencode"`
|
||||
DBPath string `yaml:"db_path" default:""`
|
||||
Config string `yaml:"config" env:"OPENCODE_CONFIG"`
|
||||
ConfigDir string `yaml:"config_dir" env:"OPENCODE_CONFIG_DIR"`
|
||||
HardTimeout Duration `yaml:"hard_timeout" default:"20m"`
|
||||
IdleTimeout Duration `yaml:"idle_timeout" default:"5m"`
|
||||
PollMs Duration `yaml:"poll_ms" default:"2s"`
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
type Pool struct {
|
||||
Bin string
|
||||
Config string
|
||||
ConfigDir string
|
||||
DBPath string
|
||||
Host string
|
||||
BasePort int
|
||||
@@ -74,7 +73,6 @@ func (p *Pool) EnsureRoot(ctx context.Context) error {
|
||||
s := &Server{
|
||||
Bin: p.Bin,
|
||||
Config: p.Config,
|
||||
ConfigDir: p.ConfigDir,
|
||||
DBPath: p.DBPath,
|
||||
Host: p.Host,
|
||||
Password: p.Password,
|
||||
@@ -104,7 +102,6 @@ func (p *Pool) Ensure(ctx context.Context, dir string) (*Server, error) {
|
||||
s := &Server{
|
||||
Bin: p.Bin,
|
||||
Config: p.Config,
|
||||
ConfigDir: p.ConfigDir,
|
||||
DBPath: p.DBPath,
|
||||
Host: p.Host,
|
||||
Password: p.Password,
|
||||
|
||||
@@ -25,7 +25,6 @@ import (
|
||||
type Server struct {
|
||||
Bin string // путь к opencode (по умолчанию "opencode")
|
||||
Config string // OPENCODE_CONFIG
|
||||
ConfigDir string // OPENCODE_CONFIG_DIR
|
||||
DBPath string // рабочая БД сервера (передам env, если задана)
|
||||
|
||||
Host string // hostname для прослушивания
|
||||
@@ -127,12 +126,13 @@ func (s *Server) serveCmd(ctx context.Context) *exec.Cmd {
|
||||
env := append(os.Environ(),
|
||||
"OPENCODE_DISABLE_AUTOUPDATE=1",
|
||||
"OPENCODE_DISABLE_MODELS_FETCH=1")
|
||||
// Агенты (analyst/dev/...) opencode находит сам через project-каталог
|
||||
// .opencode (см. internal/agents); OPENCODE_CONFIG_DIR не выставляем,
|
||||
// чтобы не перенаправлять Global.Path.config и сохранить глобальный
|
||||
// конфиг opencode (модель/провайдеры).
|
||||
if s.Config != "" {
|
||||
env = append(env, "OPENCODE_CONFIG="+s.Config)
|
||||
}
|
||||
if s.ConfigDir != "" {
|
||||
env = append(env, "OPENCODE_CONFIG_DIR="+s.ConfigDir)
|
||||
}
|
||||
if s.DBPath != "" {
|
||||
env = append(env, "OPENCODE_DB="+s.DBPath)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user