agents-relay 2.0.28 → 2.0.29

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.
@@ -135,12 +135,21 @@ export function makeExecutionDecisionAudit(input) {
135
135
  fallback: { used: input.routing.decisionEvidence?.fallback ?? false, reason: input.routing.decisionEvidence?.error },
136
136
  decision_evidence: input.routing.decisionEvidence,
137
137
  decision_input: {
138
- facts: input.decisionRequest.facts,
139
- contract: {
140
- type: input.decisionContract.type,
141
- question: input.decisionContract.question,
142
- choices: [...input.decisionContract.choices],
143
- },
138
+ type: 'staged_route_decision',
139
+ stages: input.decisionStages.map(stage => ({
140
+ name: stage.name,
141
+ facts: stage.facts,
142
+ contract: {
143
+ type: stage.contract.type,
144
+ question: stage.contract.question,
145
+ choices: [...stage.contract.choices],
146
+ },
147
+ result: {
148
+ choice: stage.result.choice,
149
+ probabilities: stage.result.probabilities ?? null,
150
+ bypassed: stage.result.bypassed ?? false,
151
+ },
152
+ })),
144
153
  },
145
154
  worker_candidates: input.workers.map(workerAuditCandidate),
146
155
  worker_gate_results: input.workerGateEvaluations.map(item => ({
@@ -59,67 +59,6 @@ function modelGateReasons(candidate, constraints, excludedRoutes, policy) {
59
59
  reasons.push('reasoning_constraint');
60
60
  return reasons;
61
61
  }
62
- const REASONING_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'];
63
- function setupName(worker, model) {
64
- if (worker.adapter === 'chatgpt')
65
- return 'account-defaults';
66
- if (model.id.startsWith('default:') || model.id.startsWith('catalog:'))
67
- return 'default';
68
- return model.profile ?? model.provider;
69
- }
70
- function setupKey(worker, model) {
71
- return worker.workerId + '/' + setupName(worker, model);
72
- }
73
- function configuredDefault(models, setup) {
74
- if (setup === 'account-defaults')
75
- return models[0];
76
- if (setup === 'default')
77
- return models.find(model => model.id.startsWith('default:'));
78
- return models.find(model => model.profile === setup && model.id.startsWith(setup + ':'));
79
- }
80
- function resolveModelAlias(raw, models, familyHint) {
81
- const normalized = raw.trim().toLowerCase();
82
- const exact = models.find(model => model.model.toLowerCase() === normalized);
83
- if (exact)
84
- return exact.model;
85
- if (familyHint) {
86
- const sameFamily = models.find(model => model.model.toLowerCase() === familyHint.toLowerCase() + '-' + normalized);
87
- if (sameFamily)
88
- return sameFamily.model;
89
- }
90
- const suffix = [...new Set(models.filter(model => model.model.toLowerCase().endsWith('-' + normalized)).map(model => model.model))];
91
- return suffix.length === 1 ? suffix[0] : raw.trim();
92
- }
93
- function userModelOverrides(rulesText, defaults, models) {
94
- const result = [];
95
- for (const line of rulesText.split(/\r?\n/)) {
96
- const match = line.match(/^\s*([a-z0-9._-]+)\s*:\s*(.+)$/i);
97
- if (!match)
98
- continue;
99
- const label = match[1].toLowerCase();
100
- const base = defaults.find(model => (model.profile ?? (model.provider === 'openai' ? 'default' : model.provider)).toLowerCase() === label || model.provider.toLowerCase() === label);
101
- if (!base)
102
- continue;
103
- const sameSetupModels = models.filter(model => model.provider === base.provider && (model.profile ?? null) === (base.profile ?? null));
104
- const familyHint = match[2].match(/\b(gpt-\d+(?:\.\d+)?)-[a-z0-9.-]+/i)?.[1];
105
- for (const segment of match[2].split(',')) {
106
- const item = segment.trim().match(/^(.+?)\s+(low|medium|high|xhigh|max|ultra)(?:\s|$)/i);
107
- if (!item)
108
- continue;
109
- const model = resolveModelAlias(item[1].trim(), sameSetupModels, familyHint);
110
- const reasoning = item[2].toLowerCase();
111
- if (result.some(candidate => candidate.provider === base.provider && candidate.model === model && (candidate.profile ?? null) === (base.profile ?? null) && candidate.reasoning === reasoning))
112
- continue;
113
- result.push({
114
- ...base,
115
- id: (base.profile ?? 'default') + ':user/' + model,
116
- model,
117
- reasoning,
118
- });
119
- }
120
- }
121
- return result;
122
- }
123
62
  function codePlan(model) {
124
63
  return {
125
64
  five_hour: model.resourceWindows?.fiveHour ? {
@@ -152,21 +91,32 @@ function workerProviderFacts(routes) {
152
91
  id: workerId,
153
92
  adapter: workerRoutes[0].worker.adapter,
154
93
  providers: Object.fromEntries([...providers.entries()].map(([provider, providerRoutes]) => {
155
- const models = providerRoutes.map(route => route.model);
156
- const configured = models.find(model => !model.id.includes(':user/')) ?? models[0];
94
+ const configured = providerRoutes.find(route => !route.model.id.startsWith('catalog:')) ?? providerRoutes[0];
157
95
  return [provider, {
158
- default: { model: configured.model, reasoning: configured.reasoning ?? null },
159
- code_plan: codePlan(configured),
96
+ default: { model: configured.model.model, reasoning: configured.model.reasoning ?? null },
97
+ code_plan: codePlan(configured.model),
160
98
  }];
161
99
  })),
162
100
  };
163
101
  });
164
102
  }
165
- function decisionRouteLabel(route) {
166
- const prefix = route.worker.workerId + '/' + route.model.provider + '/';
167
- if (!route.model.id.includes(':user/'))
168
- return prefix + 'default';
169
- return prefix + route.model.model + (route.model.reasoning ? '@' + route.model.reasoning : '');
103
+ async function decideStage(engine, name, facts, question, choices) {
104
+ if (choices.length === 0)
105
+ throw new Error('No choices available for ' + name + ' stage');
106
+ const contract = { type: 'choice', question, choices };
107
+ if (choices.length === 1)
108
+ return { name, facts, contract, result: { choice: choices[0], bypassed: true } };
109
+ const decision = await engine.decide({ facts }, contract);
110
+ if (decision.type !== 'choice')
111
+ throw new Error('Execution router expected a choice result for ' + name);
112
+ return { name, facts, contract, result: { choice: decision.choice, probabilities: decision.probabilities } };
113
+ }
114
+ function stageFacts(baseFacts, selected, candidates) {
115
+ return JSON.parse(JSON.stringify({
116
+ ...baseFacts,
117
+ selected,
118
+ ...candidates,
119
+ }));
170
120
  }
171
121
  function routeId(worker, model) {
172
122
  return worker.workerId + '::' + model.id + (model.reasoning ? '@' + model.reasoning : '');
@@ -203,11 +153,7 @@ export async function discoverExecutionRoutes(workers, requirements, options = {
203
153
  const evaluations = [];
204
154
  for (const worker of workers) {
205
155
  const workerReasons = workerGateEvaluations.find(item => item.workerId === worker.workerId)?.rejectionReasons ?? [];
206
- const discoveredModels = await discoverModelCandidates(worker, registered, options.usage, undefined, options.rulesPath);
207
- const configuredModels = discoveredModels.filter(model => !model.id.startsWith('catalog:'));
208
- const defaults = configuredModels.filter(model => model.id.startsWith('default:') || (model.profile && model.id.startsWith(model.profile + ':')) || worker.adapter === 'chatgpt');
209
- const advertised = userModelOverrides(policy.rules.text, defaults, discoveredModels);
210
- const models = [...configuredModels, ...advertised];
156
+ const models = await discoverModelCandidates(worker, registered, options.usage, undefined, options.rulesPath);
211
157
  for (const model of models) {
212
158
  const rejectionReasons = [...workerReasons, ...modelGateReasons(model, options.routingConstraints, options.excludedRoutes, policy)];
213
159
  const route = { id: routeId(worker, model), worker, model };
@@ -228,7 +174,7 @@ export async function routeExecution(workers, requirements, options = {}) {
228
174
  const chatgptRunning = options.chatgptRunning ?? 0;
229
175
  const taskFacts = options.taskFacts ?? {};
230
176
  const taskShape = executionTaskShape(taskFacts);
231
- const facts = JSON.parse(JSON.stringify({
177
+ const baseFacts = JSON.parse(JSON.stringify({
232
178
  context: {
233
179
  job: taskFacts.job ?? null,
234
180
  task: taskFacts.task ?? taskFacts,
@@ -249,31 +195,89 @@ export async function routeExecution(workers, requirements, options = {}) {
249
195
  preferred_chatgpt_concurrency: preferredChatgptConcurrency,
250
196
  },
251
197
  worker_candidates: workerProviderFacts(routes),
252
- routing_instruction: 'For each selected provider, use its default model and reasoning unless user_input_rules explicitly provides another model/reasoning option. Any override must be compatible with the selected worker/provider and pass runtime validation.',
198
+ routing_instruction: 'Interpret user_input_rules directly. Choose among the runtime-provided legal candidates at each stage. Relay does not parse user_input_rules into model or reasoning choices.',
253
199
  }));
254
- const decisionChoices = routes.map(decisionRouteLabel);
255
- const decisionRequest = { facts };
256
- const decisionContract = {
257
- type: 'choice',
258
- question: 'Choose one legal route. Each provider default is available. Additional model/reasoning routes exist only when explicitly provided by user_input_rules. Use context, code-plan quota, runtime load, and user_input_rules.',
259
- choices: decisionChoices,
260
- };
261
200
  let selected = fallback;
262
201
  let evidence = { mode: options.decisionMode ?? 'off', fallback: true };
202
+ const decisionStages = [];
263
203
  if ((options.decisionMode ?? 'off') !== 'off' && options.decisionEngine) {
264
204
  try {
265
- const decision = await options.decisionEngine.decide(decisionRequest, decisionContract);
266
- if (decision.type !== 'choice')
267
- throw new Error('Execution router expected a route choice result');
268
- const index = decisionChoices.indexOf(decision.choice);
269
- if (index < 0)
270
- throw new Error('Execution router returned an unknown route');
271
- selected = routes[index];
205
+ const providerKeys = [...new Set(routes.map(route => route.worker.workerId + '::' + route.model.provider))];
206
+ const providerOptions = providerKeys.map(key => {
207
+ const splitAt = key.lastIndexOf('::');
208
+ const worker = key.slice(0, splitAt);
209
+ const provider = key.slice(splitAt + 2);
210
+ const providerRoutes = routes.filter(route => route.worker.workerId === worker && route.model.provider === provider);
211
+ const configured = providerRoutes.find(route => !route.model.id.startsWith('catalog:')) ?? providerRoutes[0];
212
+ return {
213
+ key,
214
+ worker,
215
+ provider,
216
+ default_model: configured.model.model,
217
+ label: 'worker=' + worker + ' provider=' + provider + ' default_model=' + configured.model.model,
218
+ };
219
+ });
220
+ const providerFacts = stageFacts(baseFacts, {}, {
221
+ provider_candidates: providerOptions.map(({ label: _label, key: _key, ...option }) => option),
222
+ });
223
+ const providerStage = await decideStage(options.decisionEngine, 'provider', providerFacts, 'Choose the best worker/provider pair. Interpret user_input_rules directly together with task context, runtime load, provider defaults, and code-plan quota. When user_input_rules explicitly names providers as routing options, prefer those named providers when applicable; treat unmentioned providers as fallbacks unless other facts clearly require them.', providerOptions.map(option => option.label));
224
+ decisionStages.push(providerStage);
225
+ const selectedProvider = providerOptions.find(option => option.label === providerStage.result.choice);
226
+ if (!selectedProvider)
227
+ throw new Error('Laya selected an unknown worker/provider option');
228
+ const workerId = selectedProvider.worker;
229
+ const provider = selectedProvider.provider;
230
+ const providerRoutes = routes.filter(route => route.worker.workerId === workerId && route.model.provider === provider);
231
+ if (providerRoutes.length === 0)
232
+ throw new Error('Laya selected a worker/provider pair with no eligible routes');
233
+ const modelGroups = new Map();
234
+ for (const route of providerRoutes) {
235
+ const group = modelGroups.get(route.model.model) ?? [];
236
+ group.push(route);
237
+ modelGroups.set(route.model.model, group);
238
+ }
239
+ const modelChoices = [...modelGroups.keys()];
240
+ const modelOptions = modelChoices.map(model => {
241
+ const group = modelGroups.get(model) ?? [];
242
+ const configured = group.some(route => !route.model.id.startsWith('catalog:'));
243
+ return {
244
+ model,
245
+ configured_default: configured,
246
+ supported_reasoning: [...new Set(group.map(route => route.model.reasoning).filter((value) => Boolean(value)))],
247
+ label: 'model=' + model + ' configured_default=' + String(configured),
248
+ };
249
+ });
250
+ const modelFacts = stageFacts(baseFacts, { worker: workerId, provider }, {
251
+ model_candidates: modelOptions.map(({ label: _label, ...option }) => option),
252
+ });
253
+ const modelStage = await decideStage(options.decisionEngine, 'model', modelFacts, 'Choose the best model for the selected worker/provider. Interpret user_input_rules directly. Candidate model names come from current runtime/configuration and are not derived from the free-text rules. If user_input_rules explicitly maps a model to the current task type, size, complexity, or other current condition, follow that mapping instead of the provider default.', modelOptions.map(option => option.label));
254
+ decisionStages.push(modelStage);
255
+ const selectedModel = modelOptions.find(option => option.label === modelStage.result.choice);
256
+ if (!selectedModel)
257
+ throw new Error('Laya selected an unknown model option');
258
+ const modelRoutes = modelGroups.get(selectedModel.model) ?? [];
259
+ const reasoningChoices = [...new Set(modelRoutes.map(route => route.model.reasoning ?? 'provider-default'))];
260
+ const reasoningOptions = reasoningChoices.map(reasoning => ({
261
+ reasoning,
262
+ label: 'reasoning=' + reasoning,
263
+ }));
264
+ const reasoningFacts = stageFacts(baseFacts, { worker: workerId, provider, model: selectedModel.model }, {
265
+ reasoning_candidates: reasoningChoices,
266
+ });
267
+ const reasoningStage = await decideStage(options.decisionEngine, 'reasoning', reasoningFacts, 'Choose the reasoning level for the selected model. Interpret user_input_rules directly. If user_input_rules explicitly assigns a reasoning level to the selected model for the current conditions, follow that assignment. Use provider-default only when the model does not expose a specific reasoning level.', reasoningOptions.map(option => option.label));
268
+ decisionStages.push(reasoningStage);
269
+ const selectedReasoning = reasoningOptions.find(option => option.label === reasoningStage.result.choice);
270
+ if (!selectedReasoning)
271
+ throw new Error('Laya selected an unknown reasoning option');
272
+ const reasoning = selectedReasoning.reasoning === 'provider-default' ? null : selectedReasoning.reasoning;
273
+ const proposed = modelRoutes.find(route => (route.model.reasoning ?? null) === reasoning);
274
+ if (!proposed)
275
+ throw new Error('Laya selected a model/reasoning combination with no eligible route');
276
+ selected = proposed;
272
277
  evidence = {
273
278
  mode: options.decisionMode ?? 'active',
274
279
  engine: options.decisionEngine.name,
275
- proposed: decision.choice,
276
- probabilities: decision.probabilities,
280
+ proposed: providerStage.result.choice + '/' + selectedModel.model + '@' + selectedReasoning.reasoning,
277
281
  proposedFields: {
278
282
  worker: selected.worker.workerId,
279
283
  provider: selected.model.provider,
@@ -319,8 +323,7 @@ export async function routeExecution(workers, requirements, options = {}) {
319
323
  },
320
324
  policy,
321
325
  routing,
322
- decisionRequest,
323
- decisionContract,
326
+ decisionStages,
324
327
  });
325
328
  routing.decisionId = audit.decision_id;
326
329
  if (options.auditStore)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.28",
3
+ "version": "2.0.29",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {