fix: improve executor model discovery

This commit is contained in:
liujing
2026-07-20 17:25:33 +08:00
parent 64b3ef6c60
commit a5daca7c4d
5 changed files with 150 additions and 68 deletions
+1 -1
View File
@@ -183,7 +183,7 @@ agent-workflow web [--port <n>]
- **`--port <n>`** — specify a custom port to listen on (default: `4317`). - **`--port <n>`** — 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. - **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. - **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.<kind>.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.<kind>.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. - **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` ### `agent-workflow abort`
+50 -3
View File
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; 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"; import { saveGlobalExecutorModels, loadGlobalWorkflowConfig } from "./workflow-config.js";
describe("workflow-model-discovery helpers", () => { describe("workflow-model-discovery helpers", () => {
@@ -24,6 +24,31 @@ describe("workflow-model-discovery helpers", () => {
], ],
})).toEqual(["model-a", "model-b"]); })).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", () => { describe("saveGlobalExecutorModels", () => {
@@ -115,6 +140,25 @@ describe("discoverExecutorModels isolation", () => {
savedVars[key] = process.env[key]; savedVars[key] = process.env[key];
delete 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(() => { afterEach(() => {
@@ -148,8 +192,11 @@ describe("discoverExecutorModels isolation", () => {
expect(result.claude.error).toBeTruthy(); expect(result.claude.error).toBeTruthy();
expect(JSON.stringify(result)).not.toContain("secret-key-value"); expect(JSON.stringify(result)).not.toContain("secret-key-value");
expect(JSON.stringify(result)).not.toContain("user:pass"); expect(JSON.stringify(result)).not.toContain("user:pass");
expect(result.agy.status === "ok" || result.agy.status === "error").toBe(true); expect(result.agy).toMatchObject({ status: "ok", models: ["Test Agy Model"] });
expect(result.reasonix.status === "ok" || result.reasonix.status === "error").toBe(true); 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 () => { it("resolves Claude settings from ~/.claude/settings.json when process env is unset", async () => {
+42 -14
View File
@@ -14,8 +14,10 @@ import type { ExecutorConfig, ExecutorKind } from "./workflow-types.js";
import { EXECUTOR_KINDS } from "./workflow-types.js"; import { EXECUTOR_KINDS } from "./workflow-types.js";
import { loadGlobalWorkflowConfig } from "./workflow-config.js"; import { loadGlobalWorkflowConfig } from "./workflow-config.js";
/** Bounded discovery timeout so web requests cannot hang indefinitely. */ /** Bounded HTTP discovery timeout so web requests cannot hang indefinitely. */
const DISCOVERY_TIMEOUT_MS = 3_000; 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 { export interface ExecutorModelDiscovery {
status: "ok" | "error"; status: "ok" | "error";
@@ -141,7 +143,7 @@ function cleanError(err: unknown, secrets: string[] = []): string {
function runCommand( function runCommand(
command: string, command: string,
args: string[], args: string[],
timeoutMs = DISCOVERY_TIMEOUT_MS, timeoutMs = CLI_DISCOVERY_TIMEOUT_MS,
): Promise<{ status: number | null; stdout: string; stderr: string }> { ): Promise<{ status: number | null; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const child = spawn(command, args, { const child = spawn(command, args, {
@@ -227,30 +229,56 @@ export function extractModelIds(data: unknown): string[] {
return candidates; return candidates;
} }
function extractReasonixProviders(data: unknown): string[] { export function extractReasonixModels(data: unknown): string[] {
if (!data || typeof data !== "object") return []; if (!data || typeof data !== "object") return [];
const obj = data as Record<string, unknown>; const obj = data as Record<string, unknown>;
const providers: string[] = []; const models: string[] = [];
if (Array.isArray(obj.providers)) { if (Array.isArray(obj.providers)) {
for (const provider of obj.providers) { for (const provider of obj.providers) {
if (provider && typeof provider === "object" && typeof (provider as Record<string, unknown>).name === "string") { if (!provider || typeof provider !== "object") continue;
providers.push((provider as Record<string, unknown>).name as string); const providerObj = provider as Record<string, unknown>;
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<string, unknown>;
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) { for (const item of obj.models) {
if (typeof item === "string") { if (typeof item === "string") {
providers.push(item); models.push(item);
} else if (item && typeof item === "object" && typeof (item as Record<string, unknown>).provider === "string") { } else if (item && typeof item === "object" && typeof (item as Record<string, unknown>).provider === "string") {
providers.push((item as Record<string, unknown>).provider as string); const modelObj = item as Record<string, unknown>;
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 = [ const CLAUDE_DEFAULTS = [
@@ -275,7 +303,7 @@ async function discoverClaudeModels(config: ExecutorConfig): Promise<ExecutorMod
try { try {
const url = new URL("/v1/models", settings.baseUrl); const url = new URL("/v1/models", settings.baseUrl);
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), DISCOVERY_TIMEOUT_MS); const timer = setTimeout(() => controller.abort(), HTTP_DISCOVERY_TIMEOUT_MS);
const headers: Record<string, string> = { const headers: Record<string, string> = {
"anthropic-version": "2023-06-01", "anthropic-version": "2023-06-01",
@@ -354,8 +382,8 @@ async function discoverReasonixModels(config: ExecutorConfig): Promise<ExecutorM
throw new Error(`Failed to parse reasonix doctor output: ${cleanError(err)}`); throw new Error(`Failed to parse reasonix doctor output: ${cleanError(err)}`);
} }
const providers = extractReasonixProviders(data); const models = extractReasonixModels(data);
return { status: "ok", models: providers.length > 0 ? providers : defaults, defaults }; return { status: "ok", models: models.length > 0 ? models : defaults, defaults };
} catch (err) { } catch (err) {
return { status: "error", error: cleanError(err), defaults }; return { status: "error", error: cleanError(err), defaults };
} }
+1 -1
View File
@@ -975,7 +975,7 @@ export function getAssetsHtml(): string {
<div class="settings-field"> <div class="settings-field">
<label for="model-input-reasonix">手动输入模型</label> <label for="model-input-reasonix">手动输入模型</label>
<input id="model-input-reasonix" class="search-input" type="text" placeholder="例如 anthropic 或留空使用默认"> <input id="model-input-reasonix" class="search-input" type="text" placeholder="例如 anthropic 或留空使用默认">
<div class="settings-help">Reasonix 使用 provider 名称作为 --model;通过 reasonix doctor --json 发现。</div> <div class="settings-help">通过 reasonix doctor --json 发现 provider/model;没有具体模型时使用 provider 名称。</div>
<div class="settings-error" id="error-reasonix"></div> <div class="settings-error" id="error-reasonix"></div>
</div> </div>
</div> </div>
+56 -49
View File
@@ -36,6 +36,8 @@ function makeRequest(
describe("workflow-web", () => { describe("workflow-web", () => {
let tmpDir: string; let tmpDir: string;
let server: http.Server; let server: http.Server;
let originalHome: string | undefined;
let testHome: string;
let port = 14319; let port = 14319;
let baseUrl = `http://127.0.0.1:${port}`; let baseUrl = `http://127.0.0.1:${port}`;
let runId = "test-run-123"; let runId = "test-run-123";
@@ -46,6 +48,27 @@ describe("workflow-web", () => {
// Setup temp workflows root directory // Setup temp workflows root directory
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-web-test-")); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-web-test-"));
process.env.AGENT_WORKFLOW_DIR = tmpDir; 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); const runDir = path.join(tmpDir, hash, runId);
fs.mkdirSync(runDir, { recursive: true }); fs.mkdirSync(runDir, { recursive: true });
@@ -92,6 +115,8 @@ describe("workflow-web", () => {
afterAll(() => { afterAll(() => {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
server.close(() => { server.close(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
fs.rmSync(tmpDir, { recursive: true, force: true }); fs.rmSync(tmpDir, { recursive: true, force: true });
resolve(); resolve();
}); });
@@ -267,23 +292,14 @@ describe("workflow-web", () => {
}); });
it("accepts legitimate model values with spaces and punctuation", async () => { it("accepts legitimate model values with spaces and punctuation", async () => {
const previousHome = process.env.HOME; const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-web-model-ok-")); "Host": "127.0.0.1:14319",
process.env.HOME = tmpHome; "Origin": "http://127.0.0.1:14319",
try { "Content-Type": "application/json",
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", { }, JSON.stringify({ agy: "Claude Sonnet 4.6 (Thinking)" }));
"Host": "127.0.0.1:14319", expect(res.status).toBe(200);
"Origin": "http://127.0.0.1:14319", const saved = JSON.parse(res.body);
"Content-Type": "application/json", expect(saved.current.agy).toBe("Claude Sonnet 4.6 (Thinking)");
}, 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 });
}
}, 15_000); }, 15_000);
it("returns an observable 413 for oversized bodies instead of resetting the socket", async () => { 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 () => { it("saves and clears global model overrides through the API", async () => {
const previousHome = process.env.HOME; const saveRes = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-web-config-")); "Host": "127.0.0.1:14319",
process.env.HOME = tmpHome; "Origin": "http://127.0.0.1:14319",
try { "Content-Type": "application/json",
const saveRes = await makeRequest(`${baseUrl}/api/config/models`, "POST", { }, JSON.stringify({ claude: "claude-sonnet-4", reasonix: "anthropic" }));
"Host": "127.0.0.1:14319", expect(saveRes.status).toBe(200);
"Origin": "http://127.0.0.1:14319", const saved = JSON.parse(saveRes.body);
"Content-Type": "application/json", expect(saved.current.claude).toBe("claude-sonnet-4");
}, JSON.stringify({ claude: "claude-sonnet-4", reasonix: "anthropic" })); expect(saved.current.reasonix).toBe("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"); const configPath = path.join(testHome, ".agent-workflow", "config.json");
expect(fs.existsSync(configPath)).toBe(true); expect(fs.existsSync(configPath)).toBe(true);
const onDisk = JSON.parse(fs.readFileSync(configPath, "utf8")); const onDisk = JSON.parse(fs.readFileSync(configPath, "utf8"));
expect(onDisk.executors.claude.model).toBe("claude-sonnet-4"); expect(onDisk.executors.claude.model).toBe("claude-sonnet-4");
expect(onDisk.executors.reasonix.model).toBe("anthropic"); expect(onDisk.executors.reasonix.model).toBe("anthropic");
const clearRes = await makeRequest(`${baseUrl}/api/config/models`, "POST", { const clearRes = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
"Host": "127.0.0.1:14319", "Host": "127.0.0.1:14319",
"Origin": "http://127.0.0.1:14319", "Origin": "http://127.0.0.1:14319",
"Content-Type": "application/json", "Content-Type": "application/json",
}, JSON.stringify({ claude: null, reasonix: null })); }, JSON.stringify({ claude: null, reasonix: null }));
expect(clearRes.status).toBe(200); expect(clearRes.status).toBe(200);
const cleared = JSON.parse(clearRes.body); const cleared = JSON.parse(clearRes.body);
expect(cleared.current.claude).toBeNull(); expect(cleared.current.claude).toBeNull();
expect(cleared.current.reasonix).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 });
}
}, 15_000); }, 15_000);
it("supports HEAD request", async () => { it("supports HEAD request", async () => {