From a5daca7c4d24e1670a23f9ebaf9dd0b88979dd11 Mon Sep 17 00:00:00 2001 From: liujing Date: Mon, 20 Jul 2026 17:25:33 +0800 Subject: [PATCH] fix: improve executor model discovery --- README.md | 2 +- src/workflow-model-discovery.test.ts | 53 +++++++++++++- src/workflow-model-discovery.ts | 56 ++++++++++---- src/workflow-web-assets.ts | 2 +- src/workflow-web.test.ts | 105 ++++++++++++++------------- 5 files changed, 150 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 9618e4b..6d7d9b7 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ agent-workflow web [--port ] - **`--port `** — specify a custom port to listen on (default: `4317`). - **Security & Scope** — The server binds exclusively to the loopback interface (`127.0.0.1`) for local-only access. It refuses any external or remote listener configurations. Since executor logs can contain sensitive code, configuration details, or tokens/credentials, binding to `127.0.0.1` ensures that the console remains local. Mutation endpoints require same-origin localhost requests, JSON content type, bounded bodies, and validated model values. Credentials are never returned to the browser. - **Workflow monitoring remains read-only** — The console provides no endpoints to start, stop, decide, or otherwise control workflow runs. It parses and displays execution progress, cycles, timeline, and stdout/stderr output. It cannot recover or display private reasoning tokens/processes of underlying models that are not output to the console. -- **Writable global model settings** — The Chinese “全局执行器模型配置” view can independently set Claude, Agy, and Reasonix models. Discovery uses the current CLI environment: Claude via Anthropic `/v1/models` (with configured-default fallback), Agy via `agy models`, and Reasonix via `reasonix doctor --json` provider names. Discovery failures are isolated per executor, and manual entry always remains available. Saving atomically updates only `executors..model` in `~/.agent-workflow/config.json`, preserves unrelated fields, and clears an override when the value is emptied. Active executors are not interrupted; saved models take effect on later cycles, scope remediation, extensions, and executor retries through the existing reload path. Project configuration continues to override global values. +- **Writable global model settings** — The Chinese “全局执行器模型配置” view can independently set Claude, Agy, and Reasonix models. Discovery uses the current CLI environment: Claude via Anthropic `/v1/models` (with configured-default fallback), Agy via `agy models`, and Reasonix via `reasonix doctor --json` provider/model entries. Reasonix candidates use the CLI-compatible `provider/model` form, with provider-only fallback when no concrete model is listed. CLI discovery allows up to 15 seconds for executor startup; HTTP model discovery remains bounded separately. Discovery failures are isolated per executor, and manual entry always remains available. Saving atomically updates only `executors..model` in `~/.agent-workflow/config.json`, preserves unrelated fields, and clears an override when the value is emptied. Active executors are not interrupted; saved models take effect on later cycles, scope remediation, extensions, and executor retries through the existing reload path. Project configuration continues to override global values. - **Interaction with `logs --follow`** — The web server directly reads the persisted run files without taking any logs follow locks or interfering with the main executor loop, allowing you to use both the CLI `logs --follow` and the web console concurrently. ### `agent-workflow abort` diff --git a/src/workflow-model-discovery.test.ts b/src/workflow-model-discovery.test.ts index f5477a7..dd384fc 100644 --- a/src/workflow-model-discovery.test.ts +++ b/src/workflow-model-discovery.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { extractModelIds, discoverExecutorModels } from "./workflow-model-discovery.js"; +import { extractModelIds, extractReasonixModels, discoverExecutorModels } from "./workflow-model-discovery.js"; import { saveGlobalExecutorModels, loadGlobalWorkflowConfig } from "./workflow-config.js"; describe("workflow-model-discovery helpers", () => { @@ -24,6 +24,31 @@ describe("workflow-model-discovery helpers", () => { ], })).toEqual(["model-a", "model-b"]); }); + + it("extracts concrete Reasonix provider/model ids", () => { + expect(extractReasonixModels({ + providers: [ + { + name: "custom-opencode-ai", + models: ["deepseek-v4-flash", "deepseek-v4-pro"], + }, + { name: "anthropic", models: [] }, + ], + })).toEqual([ + "custom-opencode-ai/deepseek-v4-flash", + "custom-opencode-ai/deepseek-v4-pro", + "anthropic", + ]); + }); + + it("supports object model entries and legacy top-level models", () => { + expect(extractReasonixModels({ + providers: [{ name: "gateway", models: [{ id: "model-a" }, { name: "model-b" }] }], + })).toEqual(["gateway/model-a", "gateway/model-b"]); + expect(extractReasonixModels({ + models: [{ provider: "gateway", id: "model-c" }, "provider-only"], + })).toEqual(["gateway/model-c", "provider-only"]); + }); }); describe("saveGlobalExecutorModels", () => { @@ -115,6 +140,25 @@ describe("discoverExecutorModels isolation", () => { savedVars[key] = process.env[key]; delete process.env[key]; } + + const binDir = path.join(tmpHome, "bin"); + fs.mkdirSync(binDir, { recursive: true }); + const agyBin = path.join(binDir, "agy"); + const reasonixBin = path.join(binDir, "reasonix"); + fs.writeFileSync(agyBin, "#!/bin/sh\nprintf '%s\\n' 'Test Agy Model'\n", { mode: 0o755 }); + fs.writeFileSync(reasonixBin, `#!/bin/sh +printf '%s\\n' '{"providers":[{"name":"test-provider","models":["test-model"]}]}' +`, { mode: 0o755 }); + + const configDir = path.join(tmpHome, ".agent-workflow"); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, "config.json"), JSON.stringify({ + version: "1", + executors: { + agy: { binary: agyBin }, + reasonix: { binary: reasonixBin }, + }, + })); }); afterEach(() => { @@ -148,8 +192,11 @@ describe("discoverExecutorModels isolation", () => { expect(result.claude.error).toBeTruthy(); expect(JSON.stringify(result)).not.toContain("secret-key-value"); expect(JSON.stringify(result)).not.toContain("user:pass"); - expect(result.agy.status === "ok" || result.agy.status === "error").toBe(true); - expect(result.reasonix.status === "ok" || result.reasonix.status === "error").toBe(true); + expect(result.agy).toMatchObject({ status: "ok", models: ["Test Agy Model"] }); + expect(result.reasonix).toMatchObject({ + status: "ok", + models: ["test-provider/test-model"], + }); }); it("resolves Claude settings from ~/.claude/settings.json when process env is unset", async () => { diff --git a/src/workflow-model-discovery.ts b/src/workflow-model-discovery.ts index 696180b..0ebd608 100644 --- a/src/workflow-model-discovery.ts +++ b/src/workflow-model-discovery.ts @@ -14,8 +14,10 @@ import type { ExecutorConfig, ExecutorKind } from "./workflow-types.js"; import { EXECUTOR_KINDS } from "./workflow-types.js"; import { loadGlobalWorkflowConfig } from "./workflow-config.js"; -/** Bounded discovery timeout so web requests cannot hang indefinitely. */ -const DISCOVERY_TIMEOUT_MS = 3_000; +/** Bounded HTTP discovery timeout so web requests cannot hang indefinitely. */ +const HTTP_DISCOVERY_TIMEOUT_MS = 3_000; +/** CLI discovery can take longer while the executor loads its configuration. */ +const CLI_DISCOVERY_TIMEOUT_MS = 15_000; export interface ExecutorModelDiscovery { status: "ok" | "error"; @@ -141,7 +143,7 @@ function cleanError(err: unknown, secrets: string[] = []): string { function runCommand( command: string, args: string[], - timeoutMs = DISCOVERY_TIMEOUT_MS, + timeoutMs = CLI_DISCOVERY_TIMEOUT_MS, ): Promise<{ status: number | null; stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const child = spawn(command, args, { @@ -227,30 +229,56 @@ export function extractModelIds(data: unknown): string[] { return candidates; } -function extractReasonixProviders(data: unknown): string[] { +export function extractReasonixModels(data: unknown): string[] { if (!data || typeof data !== "object") return []; const obj = data as Record; - const providers: string[] = []; + const models: string[] = []; if (Array.isArray(obj.providers)) { for (const provider of obj.providers) { - if (provider && typeof provider === "object" && typeof (provider as Record).name === "string") { - providers.push((provider as Record).name as string); + if (!provider || typeof provider !== "object") continue; + const providerObj = provider as Record; + const name = typeof providerObj.name === "string" ? providerObj.name.trim() : ""; + if (!name) continue; + + if (Array.isArray(providerObj.models)) { + for (const model of providerObj.models) { + if (typeof model === "string" && model.trim()) { + models.push(`${name}/${model.trim()}`); + } else if (model && typeof model === "object") { + const modelObj = model as Record; + const id = typeof modelObj.id === "string" + ? modelObj.id.trim() + : typeof modelObj.name === "string" ? modelObj.name.trim() : ""; + if (id) models.push(`${name}/${id}`); + } + } + } + + // Some providers only expose a provider name. Keep it selectable as a + // fallback because Reasonix accepts provider names for these configs. + if (!Array.isArray(providerObj.models) || providerObj.models.length === 0) { + models.push(name); } } } - if (providers.length === 0 && Array.isArray(obj.models)) { + if (models.length === 0 && Array.isArray(obj.models)) { for (const item of obj.models) { if (typeof item === "string") { - providers.push(item); + models.push(item); } else if (item && typeof item === "object" && typeof (item as Record).provider === "string") { - providers.push((item as Record).provider as string); + const modelObj = item as Record; + const provider = (modelObj.provider as string).trim(); + const model = typeof modelObj.id === "string" + ? modelObj.id.trim() + : typeof modelObj.name === "string" ? modelObj.name.trim() : ""; + models.push(model ? `${provider}/${model}` : provider); } } } - return [...new Set(providers)]; + return [...new Set(models)]; } const CLAUDE_DEFAULTS = [ @@ -275,7 +303,7 @@ async function discoverClaudeModels(config: ExecutorConfig): Promise controller.abort(), DISCOVERY_TIMEOUT_MS); + const timer = setTimeout(() => controller.abort(), HTTP_DISCOVERY_TIMEOUT_MS); const headers: Record = { "anthropic-version": "2023-06-01", @@ -354,8 +382,8 @@ async function discoverReasonixModels(config: ExecutorConfig): Promise 0 ? providers : defaults, defaults }; + const models = extractReasonixModels(data); + return { status: "ok", models: models.length > 0 ? models : defaults, defaults }; } catch (err) { return { status: "error", error: cleanError(err), defaults }; } diff --git a/src/workflow-web-assets.ts b/src/workflow-web-assets.ts index a5929b6..d6c3161 100644 --- a/src/workflow-web-assets.ts +++ b/src/workflow-web-assets.ts @@ -975,7 +975,7 @@ export function getAssetsHtml(): string {
-
Reasonix 使用 provider 名称作为 --model;通过 reasonix doctor --json 发现。
+
通过 reasonix doctor --json 发现 provider/model;没有具体模型时使用 provider 名称。
diff --git a/src/workflow-web.test.ts b/src/workflow-web.test.ts index c126a70..03a1377 100644 --- a/src/workflow-web.test.ts +++ b/src/workflow-web.test.ts @@ -36,6 +36,8 @@ function makeRequest( describe("workflow-web", () => { let tmpDir: string; let server: http.Server; + let originalHome: string | undefined; + let testHome: string; let port = 14319; let baseUrl = `http://127.0.0.1:${port}`; let runId = "test-run-123"; @@ -46,6 +48,27 @@ describe("workflow-web", () => { // Setup temp workflows root directory tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-web-test-")); process.env.AGENT_WORKFLOW_DIR = tmpDir; + originalHome = process.env.HOME; + testHome = path.join(tmpDir, "home"); + process.env.HOME = testHome; + + const binDir = path.join(testHome, "bin"); + fs.mkdirSync(binDir, { recursive: true }); + const agyBin = path.join(binDir, "agy"); + const reasonixBin = path.join(binDir, "reasonix"); + fs.writeFileSync(agyBin, "#!/bin/sh\nprintf '%s\\n' 'Test Agy Model'\n", { mode: 0o755 }); + fs.writeFileSync(reasonixBin, `#!/bin/sh +printf '%s\\n' '{"providers":[{"name":"test-provider","models":["test-model"]}]}' +`, { mode: 0o755 }); + const configDir = path.join(testHome, ".agent-workflow"); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, "config.json"), JSON.stringify({ + version: "1", + executors: { + agy: { binary: agyBin }, + reasonix: { binary: reasonixBin }, + }, + })); const runDir = path.join(tmpDir, hash, runId); fs.mkdirSync(runDir, { recursive: true }); @@ -92,6 +115,8 @@ describe("workflow-web", () => { afterAll(() => { return new Promise((resolve) => { server.close(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; fs.rmSync(tmpDir, { recursive: true, force: true }); resolve(); }); @@ -267,23 +292,14 @@ describe("workflow-web", () => { }); it("accepts legitimate model values with spaces and punctuation", async () => { - const previousHome = process.env.HOME; - const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-web-model-ok-")); - process.env.HOME = tmpHome; - try { - const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", { - "Host": "127.0.0.1:14319", - "Origin": "http://127.0.0.1:14319", - "Content-Type": "application/json", - }, JSON.stringify({ agy: "Claude Sonnet 4.6 (Thinking)" })); - expect(res.status).toBe(200); - const saved = JSON.parse(res.body); - expect(saved.current.agy).toBe("Claude Sonnet 4.6 (Thinking)"); - } finally { - if (previousHome === undefined) delete process.env.HOME; - else process.env.HOME = previousHome; - fs.rmSync(tmpHome, { recursive: true, force: true }); - } + const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", { + "Host": "127.0.0.1:14319", + "Origin": "http://127.0.0.1:14319", + "Content-Type": "application/json", + }, JSON.stringify({ agy: "Claude Sonnet 4.6 (Thinking)" })); + expect(res.status).toBe(200); + const saved = JSON.parse(res.body); + expect(saved.current.agy).toBe("Claude Sonnet 4.6 (Thinking)"); }, 15_000); it("returns an observable 413 for oversized bodies instead of resetting the socket", async () => { @@ -297,40 +313,31 @@ describe("workflow-web", () => { }); it("saves and clears global model overrides through the API", async () => { - const previousHome = process.env.HOME; - const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-web-config-")); - process.env.HOME = tmpHome; - try { - const saveRes = await makeRequest(`${baseUrl}/api/config/models`, "POST", { - "Host": "127.0.0.1:14319", - "Origin": "http://127.0.0.1:14319", - "Content-Type": "application/json", - }, JSON.stringify({ claude: "claude-sonnet-4", reasonix: "anthropic" })); - expect(saveRes.status).toBe(200); - const saved = JSON.parse(saveRes.body); - expect(saved.current.claude).toBe("claude-sonnet-4"); - expect(saved.current.reasonix).toBe("anthropic"); + const saveRes = await makeRequest(`${baseUrl}/api/config/models`, "POST", { + "Host": "127.0.0.1:14319", + "Origin": "http://127.0.0.1:14319", + "Content-Type": "application/json", + }, JSON.stringify({ claude: "claude-sonnet-4", reasonix: "anthropic" })); + expect(saveRes.status).toBe(200); + const saved = JSON.parse(saveRes.body); + expect(saved.current.claude).toBe("claude-sonnet-4"); + expect(saved.current.reasonix).toBe("anthropic"); - const configPath = path.join(tmpHome, ".agent-workflow", "config.json"); - expect(fs.existsSync(configPath)).toBe(true); - const onDisk = JSON.parse(fs.readFileSync(configPath, "utf8")); - expect(onDisk.executors.claude.model).toBe("claude-sonnet-4"); - expect(onDisk.executors.reasonix.model).toBe("anthropic"); + const configPath = path.join(testHome, ".agent-workflow", "config.json"); + expect(fs.existsSync(configPath)).toBe(true); + const onDisk = JSON.parse(fs.readFileSync(configPath, "utf8")); + expect(onDisk.executors.claude.model).toBe("claude-sonnet-4"); + expect(onDisk.executors.reasonix.model).toBe("anthropic"); - const clearRes = await makeRequest(`${baseUrl}/api/config/models`, "POST", { - "Host": "127.0.0.1:14319", - "Origin": "http://127.0.0.1:14319", - "Content-Type": "application/json", - }, JSON.stringify({ claude: null, reasonix: null })); - expect(clearRes.status).toBe(200); - const cleared = JSON.parse(clearRes.body); - expect(cleared.current.claude).toBeNull(); - expect(cleared.current.reasonix).toBeNull(); - } finally { - if (previousHome === undefined) delete process.env.HOME; - else process.env.HOME = previousHome; - fs.rmSync(tmpHome, { recursive: true, force: true }); - } + const clearRes = await makeRequest(`${baseUrl}/api/config/models`, "POST", { + "Host": "127.0.0.1:14319", + "Origin": "http://127.0.0.1:14319", + "Content-Type": "application/json", + }, JSON.stringify({ claude: null, reasonix: null })); + expect(clearRes.status).toBe(200); + const cleared = JSON.parse(clearRes.body); + expect(cleared.current.claude).toBeNull(); + expect(cleared.current.reasonix).toBeNull(); }, 15_000); it("supports HEAD request", async () => {