fix(agent): refresh opencode model cache (#1830)
This commit is contained in:
@@ -297,7 +297,7 @@ func (m *Manager) bootstrapAgent(ctx context.Context, ia agents.InferenceAgent)
|
||||
m.instances[agentType] = inst
|
||||
m.mu.Unlock()
|
||||
|
||||
caps := m.probe(ctx, inst, ia)
|
||||
caps := m.probe(ctx, inst, ia, false)
|
||||
m.cache.set(caps)
|
||||
log.Info("host utility bootstrap completed",
|
||||
zap.String("status", string(caps.Status)),
|
||||
@@ -522,42 +522,22 @@ const probeTimeout = 60 * time.Second
|
||||
|
||||
// probe runs an ACP probe against the given instance and translates the result
|
||||
// into an AgentCapabilities record suitable for the cache.
|
||||
func (m *Manager) probe(ctx context.Context, inst *instance, ia agents.InferenceAgent) AgentCapabilities {
|
||||
func (m *Manager) probe(ctx context.Context, inst *instance, ia agents.InferenceAgent, refresh bool) AgentCapabilities {
|
||||
probeCtx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
defer cancel()
|
||||
|
||||
cfg := ia.InferenceConfig()
|
||||
req := &agentctlutil.ProbeRequest{
|
||||
AgentID: inst.agentType,
|
||||
InferenceConfig: &agentctlutil.InferenceConfigDTO{
|
||||
Command: cfg.Command.Args(),
|
||||
ModelFlag: cfg.ModelFlag.Args(),
|
||||
WorkDir: inst.workDir,
|
||||
StripEnv: agents.StripEnvFor(ia),
|
||||
},
|
||||
}
|
||||
req := buildProbeRequest(inst, ia, refresh)
|
||||
resp, err := inst.client.Probe(probeCtx, req)
|
||||
now := time.Now()
|
||||
if err != nil {
|
||||
return AgentCapabilities{
|
||||
AgentType: inst.agentType,
|
||||
Status: StatusFailed,
|
||||
Error: err.Error(),
|
||||
LastCheckedAt: now,
|
||||
}
|
||||
return probeFailureCapabilities(inst.agentType, StatusFailed, err.Error(), 0, now)
|
||||
}
|
||||
if !resp.Success {
|
||||
status := StatusFailed
|
||||
if isAuthError(resp.Error) {
|
||||
status = StatusAuthRequired
|
||||
}
|
||||
return AgentCapabilities{
|
||||
AgentType: inst.agentType,
|
||||
Status: status,
|
||||
Error: resp.Error,
|
||||
DurationMs: resp.DurationMs,
|
||||
LastCheckedAt: now,
|
||||
}
|
||||
return probeFailureCapabilities(inst.agentType, status, resp.Error, resp.DurationMs, now)
|
||||
}
|
||||
caps := AgentCapabilities{
|
||||
AgentType: inst.agentType,
|
||||
@@ -616,6 +596,40 @@ func (m *Manager) probe(ctx context.Context, inst *instance, ia agents.Inference
|
||||
return caps
|
||||
}
|
||||
|
||||
func buildProbeRequest(
|
||||
inst *instance,
|
||||
ia agents.InferenceAgent,
|
||||
refresh bool,
|
||||
) *agentctlutil.ProbeRequest {
|
||||
cfg := ia.InferenceConfig()
|
||||
return &agentctlutil.ProbeRequest{
|
||||
AgentID: inst.agentType,
|
||||
Refresh: refresh,
|
||||
InferenceConfig: &agentctlutil.InferenceConfigDTO{
|
||||
Command: cfg.Command.Args(),
|
||||
ModelFlag: cfg.ModelFlag.Args(),
|
||||
WorkDir: inst.workDir,
|
||||
StripEnv: agents.StripEnvFor(ia),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func probeFailureCapabilities(
|
||||
agentType string,
|
||||
status Status,
|
||||
errorMessage string,
|
||||
durationMs int,
|
||||
checkedAt time.Time,
|
||||
) AgentCapabilities {
|
||||
return AgentCapabilities{
|
||||
AgentType: agentType,
|
||||
Status: status,
|
||||
Error: errorMessage,
|
||||
DurationMs: durationMs,
|
||||
LastCheckedAt: checkedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// isAuthError is a coarse heuristic — ACP auth failures bubble up as string
|
||||
// errors from the SDK without a distinct code. We match obvious markers so
|
||||
// the UI can route the user to the auth flow; anything unmatched stays as
|
||||
|
||||
@@ -101,7 +101,7 @@ func TestProbePreservesConfigOptionDescriptions(t *testing.T) {
|
||||
agentType: "codex-acp",
|
||||
workDir: t.TempDir(),
|
||||
client: agentctlclient.NewClient(host, port, log),
|
||||
}, &installedInferenceAgent{id: "codex-acp"})
|
||||
}, &installedInferenceAgent{id: "codex-acp"}, false)
|
||||
|
||||
raw, err := json.Marshal(caps.ConfigOptions)
|
||||
require.NoError(t, err)
|
||||
@@ -245,6 +245,7 @@ func TestRefreshRecreatesStaleCachedInstance(t *testing.T) {
|
||||
require.NoError(t, reg.Register(&installedInferenceAgent{id: agentType}))
|
||||
|
||||
var probeCalls atomic.Int32
|
||||
var refreshRequested atomic.Bool
|
||||
instanceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/health":
|
||||
@@ -259,6 +260,11 @@ func TestRefreshRecreatesStaleCachedInstance(t *testing.T) {
|
||||
return
|
||||
}
|
||||
probeCalls.Add(1)
|
||||
var request agentctlutil.ProbeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Errorf("decode probe request: %v", err)
|
||||
}
|
||||
refreshRequested.Store(request.Refresh)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
err := json.NewEncoder(w).Encode(agentctlutil.ProbeResponse{
|
||||
Success: true,
|
||||
@@ -321,6 +327,7 @@ func TestRefreshRecreatesStaleCachedInstance(t *testing.T) {
|
||||
require.Equal(t, int32(1), createCalls.Load())
|
||||
require.Equal(t, int32(1), deleteCalls.Load())
|
||||
require.Equal(t, int32(1), probeCalls.Load())
|
||||
require.True(t, refreshRequested.Load())
|
||||
|
||||
mgr.mu.RLock()
|
||||
fresh := mgr.instances[agentType]
|
||||
|
||||
@@ -50,7 +50,7 @@ func (m *Manager) Refresh(ctx context.Context, agentType string) (AgentCapabilit
|
||||
})
|
||||
return AgentCapabilities{}, err
|
||||
}
|
||||
caps := m.probe(ctx, inst, ia)
|
||||
caps := m.probe(ctx, inst, ia, true)
|
||||
m.cache.set(caps)
|
||||
return caps, nil
|
||||
}
|
||||
|
||||
@@ -311,6 +311,11 @@ func (e *ACPInferenceExecutor) Probe(ctx context.Context, req *ProbeRequest) (*P
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
isOpenCode := isOpenCodeACPCommand(cfg.Command)
|
||||
var refreshedOpenCodeModels []ProbeModel
|
||||
if isOpenCode && req.Refresh {
|
||||
refreshedOpenCodeModels = e.loadOpenCodeModels(ctx, resolvedCmd, workDir, true)
|
||||
}
|
||||
|
||||
// Probes intentionally omit the model flag so session/new returns the agent's
|
||||
// default model and the complete availableModels list.
|
||||
@@ -354,8 +359,11 @@ func (e *ACPInferenceExecutor) Probe(ctx context.Context, req *ProbeRequest) (*P
|
||||
DurationMs: int(time.Since(startTime).Milliseconds()),
|
||||
}, nil
|
||||
}
|
||||
if len(resp.Models) == 0 && isOpenCodeACPCommand(cfg.Command) {
|
||||
e.applyOpenCodeModelsFallback(ctx, resp, resolvedCmd, workDir)
|
||||
if len(resp.Models) == 0 && isOpenCode {
|
||||
if len(refreshedOpenCodeModels) == 0 {
|
||||
refreshedOpenCodeModels = e.loadOpenCodeModels(ctx, resolvedCmd, workDir, false)
|
||||
}
|
||||
resp.Models = refreshedOpenCodeModels
|
||||
}
|
||||
|
||||
resp.Success = true
|
||||
@@ -371,29 +379,41 @@ func isOpenCodeACPCommand(command []string) bool {
|
||||
command[1] == openCodeACPSubcommand
|
||||
}
|
||||
|
||||
// applyOpenCodeModelsFallback fills an otherwise empty probe model list from
|
||||
// OpenCode's CLI model listing.
|
||||
func (e *ACPInferenceExecutor) applyOpenCodeModelsFallback(ctx context.Context, resp *ProbeResponse, resolvedCmd, workDir string) {
|
||||
models, err := probeOpenCodeModels(ctx, resolvedCmd, workDir)
|
||||
// loadOpenCodeModels runs OpenCode's CLI model listing and logs failures
|
||||
// without failing the broader ACP capability probe.
|
||||
func (e *ACPInferenceExecutor) loadOpenCodeModels(
|
||||
ctx context.Context,
|
||||
resolvedCmd string,
|
||||
workDir string,
|
||||
refresh bool,
|
||||
) []ProbeModel {
|
||||
models, err := probeOpenCodeModels(ctx, resolvedCmd, workDir, refresh)
|
||||
if err != nil {
|
||||
e.logger.Warn("ACP probe: failed to list opencode models",
|
||||
zap.String("command", resolvedCmd),
|
||||
zap.Error(err))
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if len(models) == 0 {
|
||||
e.logger.Warn("ACP probe: opencode models returned no valid model entries",
|
||||
zap.String("command", resolvedCmd))
|
||||
return
|
||||
}
|
||||
resp.Models = models
|
||||
return models
|
||||
}
|
||||
|
||||
// probeOpenCodeModels runs the OpenCode model-listing command and converts its
|
||||
// output into probe model entries.
|
||||
func probeOpenCodeModels(ctx context.Context, resolvedCmd, workDir string) ([]ProbeModel, error) {
|
||||
// probeOpenCodeModels lists OpenCode's models, optionally refreshing its cache.
|
||||
func probeOpenCodeModels(
|
||||
ctx context.Context,
|
||||
resolvedCmd string,
|
||||
workDir string,
|
||||
refresh bool,
|
||||
) ([]ProbeModel, error) {
|
||||
args := []string{"models"}
|
||||
if refresh {
|
||||
args = append(args, "--refresh")
|
||||
}
|
||||
//nolint:gosec // resolvedCmd is from the same hard-coded allow-list used to launch the ACP probe.
|
||||
cmd := exec.CommandContext(ctx, resolvedCmd, "models")
|
||||
cmd := exec.CommandContext(ctx, resolvedCmd, args...)
|
||||
cmd.Dir = workDir
|
||||
cmd.Env = environWithNoColor(os.Environ())
|
||||
out, err := cmd.Output()
|
||||
|
||||
@@ -66,6 +66,83 @@ func TestProbeCleansUpDescendantProcessOnTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeOpenCodeModelsHonorsRefresh(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
agentPath := filepath.Join(tmp, openCodeCommand)
|
||||
writeExecutable(t, agentPath, `#!/bin/sh
|
||||
if [ "$1" = "models" ] && [ "$2" = "--refresh" ]; then
|
||||
echo "opencode/fresh"
|
||||
else
|
||||
echo "opencode/stale"
|
||||
fi
|
||||
`)
|
||||
|
||||
cached, err := probeOpenCodeModels(context.Background(), agentPath, tmp, false)
|
||||
if err != nil {
|
||||
t.Fatalf("probeOpenCodeModels returned error: %v", err)
|
||||
}
|
||||
if len(cached) != 1 || cached[0].ID != "opencode/stale" {
|
||||
t.Fatalf("cached models = %#v, want cached model list", cached)
|
||||
}
|
||||
|
||||
refreshed, err := probeOpenCodeModels(context.Background(), agentPath, tmp, true)
|
||||
if err != nil {
|
||||
t.Fatalf("refresh probeOpenCodeModels returned error: %v", err)
|
||||
}
|
||||
if len(refreshed) != 1 || refreshed[0].ID != "opencode/fresh" {
|
||||
t.Fatalf("refreshed models = %#v, want refreshed model list", refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeRefreshesOpenCodeCacheBeforeACPSession(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
marker := filepath.Join(tmp, "models-refreshed")
|
||||
agentPath := filepath.Join(tmp, openCodeCommand)
|
||||
writeExecutable(t, agentPath, `#!/bin/sh
|
||||
if [ "$1" = "models" ]; then
|
||||
if [ "$2" = "--refresh" ]; then
|
||||
touch "$OPENCODE_REFRESH_MARKER"
|
||||
fi
|
||||
echo "opencode/fresh"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
read -r INITIALIZE
|
||||
INITIALIZE_ID=$(printf '%s' "$INITIALIZE" | sed -n 's/.*"id":\([^,}]*\).*/\1/p')
|
||||
printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{}}}\n' "$INITIALIZE_ID"
|
||||
|
||||
read -r NEW_SESSION
|
||||
SESSION_ID=$(printf '%s' "$NEW_SESSION" | sed -n 's/.*"id":\([^,}]*\).*/\1/p')
|
||||
MODEL="opencode/stale"
|
||||
if [ -f "$OPENCODE_REFRESH_MARKER" ]; then
|
||||
MODEL="opencode/fresh"
|
||||
fi
|
||||
printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"test","configOptions":[{"type":"select","id":"model","name":"Model","category":"model","currentValue":"%s","options":[{"value":"%s","name":"%s"}]}]}}\n' "$SESSION_ID" "$MODEL" "$MODEL" "$MODEL"
|
||||
cat >/dev/null
|
||||
`)
|
||||
t.Setenv("PATH", tmp+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv("OPENCODE_REFRESH_MARKER", marker)
|
||||
|
||||
executor := NewACPInferenceExecutor(zap.NewNop())
|
||||
resp, err := executor.Probe(context.Background(), &ProbeRequest{
|
||||
AgentID: "opencode-acp",
|
||||
Refresh: true,
|
||||
InferenceConfig: &InferenceConfigDTO{
|
||||
Command: []string{openCodeCommand, openCodeACPSubcommand},
|
||||
WorkDir: tmp,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Probe returned error: %v", err)
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("Probe failed: %s", resp.Error)
|
||||
}
|
||||
if len(resp.Models) != 1 || resp.Models[0].ID != "opencode/fresh" {
|
||||
t.Fatalf("models = %#v, want refreshed ACP model list", resp.Models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForACPProcessGroupExitHonorsContextCancellation(t *testing.T) {
|
||||
cmd := exec.Command("sleep", "30")
|
||||
setACPCommandProcAttr(cmd)
|
||||
|
||||
@@ -60,6 +60,10 @@ type ProbeRequest struct {
|
||||
// AgentID is the agent to probe (e.g., "claude-acp", "codex-acp").
|
||||
AgentID string `json:"agent_id" binding:"required"`
|
||||
|
||||
// Refresh asks agent-specific discovery fallbacks to invalidate their own
|
||||
// model caches. ACP session discovery itself always starts a fresh process.
|
||||
Refresh bool `json:"refresh,omitempty"`
|
||||
|
||||
// InferenceConfig is the agent's inference configuration.
|
||||
// Command and WorkDir are required; Model is intentionally omitted for probes.
|
||||
InferenceConfig *InferenceConfigDTO `json:"inference_config,omitempty"`
|
||||
|
||||
Reference in New Issue
Block a user