agents-relay 1.0.109 → 1.0.110
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +3 -3
- package/dist/dashboard-html.js +1 -1
- package/dist/decision-audit.js +3 -0
- package/dist/model-redecision.js +3 -1
- package/dist/model-routing.js +41 -7
- package/dist/reconciler-review.js +6 -1
- package/dist/workspace.js +1 -1
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -181,7 +181,7 @@ async function runTaskCommand(action, args) {
|
|
|
181
181
|
if (action === 'retry' || action === 'cancel') {
|
|
182
182
|
if (action === 'retry')
|
|
183
183
|
task.maxAttempts = extendRetryBudget(task);
|
|
184
|
-
await transitionTaskEvent(task, action === 'retry' ? 'retry' : 'cancel');
|
|
184
|
+
await transitionTaskEvent(task, action === 'retry' ? 'retry' : task.state === 'RUNNING' ? 'task.cancelled' : 'cancel');
|
|
185
185
|
task.error = action === 'retry' ? null : 'Cancelled by user';
|
|
186
186
|
task.leaseOwner = null;
|
|
187
187
|
task.leaseExpiresAt = null;
|
|
@@ -429,9 +429,9 @@ export function dashboardTaskAction(store, reconciler, action) {
|
|
|
429
429
|
task.error = null;
|
|
430
430
|
}
|
|
431
431
|
else {
|
|
432
|
-
if (!['DRAFT', 'QUEUED', 'BLOCKED', 'FAILED'].includes(task.state))
|
|
432
|
+
if (!['DRAFT', 'QUEUED', 'RUNNING', 'BLOCKED', 'FAILED'].includes(task.state))
|
|
433
433
|
throw new Error(`Task ${task.id} is not cancellable (${task.state})`);
|
|
434
|
-
await transitionTaskEvent(task, 'cancel');
|
|
434
|
+
await transitionTaskEvent(task, task.state === 'RUNNING' ? 'task.cancelled' : 'cancel');
|
|
435
435
|
task.error = 'Cancelled by user';
|
|
436
436
|
}
|
|
437
437
|
task.leaseOwner = null;
|
package/dist/dashboard-html.js
CHANGED
|
@@ -203,7 +203,7 @@ export const dashboardHtml = `<!doctype html>
|
|
|
203
203
|
<div class="panel-body">
|
|
204
204
|
<div class="rules-editor">
|
|
205
205
|
<p class="rules-help">Free-text guidance loaded by the model decider for every routing decision. Hard capability and health gates remain enforced by code.</p>
|
|
206
|
-
<textarea id="rules-text" class="rules-textarea" spellcheck="true" aria-label="Model router rules" placeholder="Example: Prefer
|
|
206
|
+
<textarea id="rules-text" class="rules-textarea" spellcheck="true" aria-label="Model router rules" placeholder="Example: ZAI only glm-5.3-flash high thinking. Prefer it while paid quota is abundant. Non-OpenAI models enter the candidate set only when the provider is configured and the concrete model is named here."></textarea>
|
|
207
207
|
<div class="rules-footer"><span id="rules-status" class="subtle" aria-live="polite"></span><button id="rules-save" class="rules-save" type="button">Save rules</button></div>
|
|
208
208
|
</div>
|
|
209
209
|
</div>
|
package/dist/decision-audit.js
CHANGED
|
@@ -42,6 +42,9 @@ export function makeDecisionAudit(input) {
|
|
|
42
42
|
if (item.resourceHealthy === false)
|
|
43
43
|
reasons.push('resource_unhealthy');
|
|
44
44
|
const constraints = input.contract.constraints;
|
|
45
|
+
const excludedRoutes = Array.isArray(input.contract.excludedRoutes) ? input.contract.excludedRoutes : [];
|
|
46
|
+
if (excludedRoutes.some(route => (!route.provider || route.provider === item.provider) && (!route.model || route.model === item.model) && (route.profile === undefined || route.profile === item.profile)))
|
|
47
|
+
reasons.push('prior_failed_route');
|
|
45
48
|
if (typeof constraints?.provider === 'string' && constraints.provider !== item.provider)
|
|
46
49
|
reasons.push('provider_constraint');
|
|
47
50
|
if (typeof constraints?.model === 'string' && constraints.model !== item.model)
|
package/dist/model-redecision.js
CHANGED
|
@@ -23,16 +23,18 @@ export async function redecideFailedTask(options) {
|
|
|
23
23
|
if (options.evidence.classification !== 'model_provider_runtime')
|
|
24
24
|
throw new Error('Model re-decision requires model/provider/runtime evidence');
|
|
25
25
|
const usage = await readUsageSnapshot(options.usage);
|
|
26
|
-
const candidates = await discoverModelCandidates(options.worker, options.registered, usage);
|
|
26
|
+
const candidates = await discoverModelCandidates(options.worker, options.registered, usage, undefined, options.rulesPath);
|
|
27
27
|
const result = await routeModel(options.worker, candidates, {
|
|
28
28
|
decisionEngine: options.decisionEngine,
|
|
29
29
|
decisionMode: options.decisionMode,
|
|
30
30
|
policyPath: options.policyPath,
|
|
31
|
+
rulesPath: options.rulesPath,
|
|
31
32
|
auditStore: options.auditStore,
|
|
32
33
|
auditTask: options.task,
|
|
33
34
|
taskFacts: { kind: options.task.kind ?? 'work', capabilities: options.task.capabilities, prior_routing: options.task.routing ?? null, fresh_usage_generated_at: usage.generatedAt },
|
|
34
35
|
priorDecisionId: options.task.routing?.decisionId,
|
|
35
36
|
priorFailure: options.evidence,
|
|
37
|
+
excludedRoutes: options.task.routing ? [{ provider: options.task.routing.provider, model: options.task.routing.model, profile: options.task.routing.profile }] : [],
|
|
36
38
|
});
|
|
37
39
|
await options.updateRouting(options.task, result.routing);
|
|
38
40
|
if (options.auditStore && options.task.routing?.decisionId)
|
package/dist/model-routing.js
CHANGED
|
@@ -4,7 +4,7 @@ import { readdir, readFile } from 'node:fs/promises';
|
|
|
4
4
|
import { chooseWithDecision } from './decision.js';
|
|
5
5
|
import { usageForProvider } from './worker-routing.js';
|
|
6
6
|
import { makeDecisionAudit } from './decision-audit.js';
|
|
7
|
-
import { loadRoutingPolicy, policyAllows, policyRank } from './routing-policy.js';
|
|
7
|
+
import { loadModelRouterRules, loadRoutingPolicy, policyAllows, policyRank } from './routing-policy.js';
|
|
8
8
|
function topLevelCodexConfig(text) {
|
|
9
9
|
const result = {};
|
|
10
10
|
let inSection = false;
|
|
@@ -38,13 +38,34 @@ async function readConfig(path) {
|
|
|
38
38
|
return null;
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
+
function compact(value) {
|
|
42
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
43
|
+
}
|
|
44
|
+
function modelAliases(model) {
|
|
45
|
+
const aliases = new Set([compact(model)]);
|
|
46
|
+
const firstDigit = model.search(/\d/);
|
|
47
|
+
if (firstDigit >= 0)
|
|
48
|
+
aliases.add(compact(model.slice(firstDigit)));
|
|
49
|
+
return [...aliases].filter(Boolean);
|
|
50
|
+
}
|
|
51
|
+
export function nonOpenAiModelAuthorized(provider, model, rulesText) {
|
|
52
|
+
if (provider === 'openai')
|
|
53
|
+
return true;
|
|
54
|
+
const rules = compact(rulesText);
|
|
55
|
+
if (!rules.includes(compact(provider)))
|
|
56
|
+
return false;
|
|
57
|
+
return modelAliases(model).some(alias => alias.length >= 3 && rules.includes(alias));
|
|
58
|
+
}
|
|
41
59
|
function withResource(candidate, usage) {
|
|
42
60
|
const resource = usageForProvider(usage, candidate.provider);
|
|
43
61
|
return { ...candidate, resourceHealthy: resource.healthy, resourceUsedPercent: resource.usedPercent, resourceRemainingPercent: resource.remainingPercent, resourceResetAt: resource.resetAt, resourceResetInMinutes: resource.resetInMinutes, resourceWindows: resource.windows };
|
|
44
62
|
}
|
|
45
|
-
export async function discoverModelCandidates(worker, registered, usage, codexHome = process.env.CODEX_HOME ?? join(homedir(), '.codex')) {
|
|
63
|
+
export async function discoverModelCandidates(worker, registered, usage, codexHome = process.env.CODEX_HOME ?? join(homedir(), '.codex'), rulesPath) {
|
|
46
64
|
const registration = registered.find(item => item.id === worker.workerId);
|
|
47
65
|
if (registration?.runtime.provider && registration.runtime.model) {
|
|
66
|
+
const rules = await loadModelRouterRules(rulesPath);
|
|
67
|
+
if (!nonOpenAiModelAuthorized(registration.runtime.provider, registration.runtime.model, rules.text))
|
|
68
|
+
return [];
|
|
48
69
|
return [withResource({
|
|
49
70
|
id: `${registration.runtime.provider}/${registration.runtime.model}`,
|
|
50
71
|
provider: registration.runtime.provider,
|
|
@@ -55,9 +76,13 @@ export async function discoverModelCandidates(worker, registered, usage, codexHo
|
|
|
55
76
|
return [withResource({ id: 'chatgpt/account-defaults', provider: 'chatgpt', model: 'account-defaults' }, usage)];
|
|
56
77
|
}
|
|
57
78
|
const base = await readConfig(join(codexHome, 'config.toml')) ?? {};
|
|
79
|
+
const rules = await loadModelRouterRules(rulesPath);
|
|
58
80
|
const candidates = [];
|
|
59
|
-
if (base.model)
|
|
60
|
-
|
|
81
|
+
if (base.model) {
|
|
82
|
+
const provider = base.provider ?? 'openai';
|
|
83
|
+
if (nonOpenAiModelAuthorized(provider, base.model, rules.text))
|
|
84
|
+
candidates.push(withResource({ id: `default:${provider}/${base.model}`, provider, model: base.model, reasoning: base.reasoning }, usage));
|
|
85
|
+
}
|
|
61
86
|
let files = [];
|
|
62
87
|
try {
|
|
63
88
|
files = (await readdir(codexHome)).filter(name => name.endsWith('.config.toml')).sort();
|
|
@@ -69,16 +94,25 @@ export async function discoverModelCandidates(worker, registered, usage, codexHo
|
|
|
69
94
|
continue;
|
|
70
95
|
const profile = basename(file, '.config.toml');
|
|
71
96
|
const provider = config.provider ?? base.provider ?? 'openai';
|
|
97
|
+
if (!nonOpenAiModelAuthorized(provider, config.model, rules.text))
|
|
98
|
+
continue;
|
|
72
99
|
candidates.push(withResource({ id: `${profile}:${provider}/${config.model}`, provider, model: config.model, profile, reasoning: config.reasoning ?? base.reasoning }, usage));
|
|
73
100
|
}
|
|
74
101
|
if (candidates.length === 0)
|
|
75
102
|
candidates.push(withResource({ id: 'default:openai/gpt-5.6-luna', provider: 'openai', model: 'gpt-5.6-luna' }, usage));
|
|
76
103
|
return candidates.filter((candidate, index, all) => all.findIndex(item => item.id === candidate.id) === index);
|
|
77
104
|
}
|
|
78
|
-
function
|
|
105
|
+
function sameRoute(candidate, route) {
|
|
106
|
+
return (!route.provider || candidate.provider === route.provider)
|
|
107
|
+
&& (!route.model || candidate.model === route.model)
|
|
108
|
+
&& (route.profile === undefined || candidate.profile === route.profile);
|
|
109
|
+
}
|
|
110
|
+
function eligibleModels(candidates, constraints, policy, excludedRoutes = []) {
|
|
79
111
|
return candidates.filter(candidate => {
|
|
80
112
|
if (candidate.resourceHealthy === false)
|
|
81
113
|
return false;
|
|
114
|
+
if (excludedRoutes.some(route => sameRoute(candidate, route)))
|
|
115
|
+
return false;
|
|
82
116
|
if (policy && !policyAllows(candidate, policy.policy))
|
|
83
117
|
return false;
|
|
84
118
|
if (constraints?.provider && candidate.provider !== constraints.provider)
|
|
@@ -100,7 +134,7 @@ function deterministicModel(candidates, taskKind, policy) {
|
|
|
100
134
|
}
|
|
101
135
|
export async function routeModel(worker, candidates, options = {}) {
|
|
102
136
|
const policy = await loadRoutingPolicy(options.policyPath, options.rulesPath);
|
|
103
|
-
const eligible = eligibleModels(candidates, options.constraints, policy);
|
|
137
|
+
const eligible = eligibleModels(candidates, options.constraints, policy, options.excludedRoutes);
|
|
104
138
|
if (eligible.length === 0)
|
|
105
139
|
throw new Error(`No eligible model profile is usable by worker ${worker.workerId}`);
|
|
106
140
|
const taskKind = typeof options.taskFacts?.kind === 'string' ? options.taskFacts.kind : undefined;
|
|
@@ -130,7 +164,7 @@ export async function routeModel(worker, candidates, options = {}) {
|
|
|
130
164
|
const audit = makeDecisionAudit({
|
|
131
165
|
task: options.auditTask,
|
|
132
166
|
worker: { id: worker.workerId, adapter: worker.adapter, capabilities: [...worker.capabilities] },
|
|
133
|
-
contract: { question: 'model-profile-selection', task: options.taskFacts ?? {}, constraints: options.constraints ?? {} },
|
|
167
|
+
contract: { question: 'model-profile-selection', task: options.taskFacts ?? {}, constraints: options.constraints ?? {}, excludedRoutes: options.excludedRoutes ?? [] },
|
|
134
168
|
policy,
|
|
135
169
|
candidates,
|
|
136
170
|
eligibleCandidates: eligible,
|
|
@@ -129,7 +129,12 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
129
129
|
});
|
|
130
130
|
const models = await discoverModelCandidates(routedWorker.candidate, registered, usage);
|
|
131
131
|
const routingConstraints = task.routing ? { provider: task.routing.provider, model: task.routing.model, profile: task.routing.profile, reasoning: task.routing.reasoning } : undefined;
|
|
132
|
-
const
|
|
132
|
+
const parent = task.agentName === 'troubleshooter' && task.parentTaskId ? job.tasks.find(item => item.id === task.parentTaskId) : undefined;
|
|
133
|
+
const parentRouteFailedBeforeStart = Boolean(parent?.routing && /exited before publishing task\.started|worker_exit_before_start|launch failed/i.test(parent.error ?? parent.failureReason ?? ''));
|
|
134
|
+
const excludedRoutes = parentRouteFailedBeforeStart && parent?.routing
|
|
135
|
+
? [{ provider: parent.routing.provider, model: parent.routing.model, profile: parent.routing.profile }]
|
|
136
|
+
: [];
|
|
137
|
+
const routedModel = await routeModel(routedWorker.candidate, models, { decisionEngine: this.options.decisionEngine, decisionMode: this.options.decisionMode, taskFacts: { ...taskFacts, recovery_parent_failed_route: excludedRoutes[0] ?? null }, constraints: routingConstraints, excludedRoutes, auditStore: this.options.decisionAuditStore, auditTask: task });
|
|
133
138
|
task.workerSelection = routedWorker.selection;
|
|
134
139
|
task.adapter = routedWorker.candidate.adapter;
|
|
135
140
|
task.routing = routedModel.routing;
|
package/dist/workspace.js
CHANGED
|
@@ -35,7 +35,7 @@ export async function discoverGitRepositories(root) {
|
|
|
35
35
|
catch {
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
38
|
-
await Promise.all(entries.filter(entry => entry.isDirectory() &&
|
|
38
|
+
await Promise.all(entries.filter(entry => entry.isDirectory() && entry.name !== 'node_modules' && !entry.name.startsWith('.')).map(entry => visit(join(directory, entry.name))));
|
|
39
39
|
};
|
|
40
40
|
await visit(resolve(root));
|
|
41
41
|
return found.sort((a, b) => relative(root, a).localeCompare(relative(root, b)));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-relay",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.110",
|
|
4
4
|
"description": "Durable async agent jobs coordinated through GitHub pull requests",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"build": "tsc -p tsconfig.json && chmod +x dist/cli.js dist/relayd.js",
|
|
12
|
-
"test": "npm run build && node --test test/*.test.js",
|
|
12
|
+
"test": "npm run build && node --test --test-force-exit test/*.test.js",
|
|
13
13
|
"test:chatgpt-temp-quality": "AGENTS_RELAY_RUN_CHATGPT_TEMP_QUALITY=1 AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT=/Users/chengli/Workspace/neo/skills/chatgpt-browser-worker/agents/browser-worker.agent.md sh -c \"npm run build && node test/quality/run-temporary-chat.mjs\"",
|
|
14
14
|
"dev": "tsx src/cli.ts"
|
|
15
15
|
},
|