agents-relay 2.0.12 → 2.0.14

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.
@@ -1,7 +1,7 @@
1
1
  import { appendFile, mkdir, readFile } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
3
  import { randomUUID, createHash } from 'node:crypto';
4
- export const DECISION_AUDIT_SCHEMA_VERSION = 3;
4
+ export const DECISION_AUDIT_SCHEMA_VERSION = 5;
5
5
  export class JsonlDecisionAuditStore {
6
6
  path;
7
7
  constructor(path) {
@@ -79,16 +79,6 @@ function workerAuditCandidate(worker) {
79
79
  browserWorkspaceCapacity: worker.browserWorkspaceCapacity ?? null,
80
80
  };
81
81
  }
82
- function workerGateResult(worker, requirements) {
83
- const rejectionReasons = [];
84
- if (worker.availability === 'offline')
85
- rejectionReasons.push('worker_offline');
86
- if (requirements.runtime && worker.runtime !== requirements.runtime)
87
- rejectionReasons.push('runtime_constraint');
88
- if (worker.adapter === 'chatgpt' && worker.browserWorkspaceCapacity?.available === 0)
89
- rejectionReasons.push('browser_workspace_exhausted');
90
- return { worker_id: worker.workerId, eligible: rejectionReasons.length === 0, rejection_reasons: rejectionReasons };
91
- }
92
82
  function executionRouteAuditCandidate(route) {
93
83
  return {
94
84
  id: route.id,
@@ -109,9 +99,9 @@ function executionRouteAuditCandidate(route) {
109
99
  };
110
100
  }
111
101
  export function makeExecutionDecisionAudit(input) {
112
- const routeRecords = input.routes.map(executionRouteAuditCandidate);
102
+ const routeRecords = input.evaluations.map(item => executionRouteAuditCandidate(item.route));
103
+ const eligibleRouteRecords = input.evaluations.filter(item => item.eligible).map(item => executionRouteAuditCandidate(item.route));
113
104
  const selectedRecord = executionRouteAuditCandidate(input.selected);
114
- const workerGateResults = input.workers.map(worker => workerGateResult(worker, input.requirements));
115
105
  return {
116
106
  record_type: 'decision',
117
107
  schema_version: DECISION_AUDIT_SCHEMA_VERSION,
@@ -133,13 +123,29 @@ export function makeExecutionDecisionAudit(input) {
133
123
  rules: input.policy.rules,
134
124
  },
135
125
  candidates: routeRecords,
136
- gate_results: routeRecords.map(record => ({ candidate_id: String(record.id), eligible: true, rejection_reasons: [] })),
137
- eligible_candidates: routeRecords,
126
+ gate_results: input.evaluations.map(item => ({
127
+ candidate_id: item.route.id,
128
+ eligible: item.eligible,
129
+ rejection_reasons: [...item.rejectionReasons],
130
+ })),
131
+ eligible_candidates: eligibleRouteRecords,
138
132
  selected: selectedRecord,
139
133
  engine: input.routing.decidedBy,
140
134
  fallback: { used: input.routing.decisionEvidence?.fallback ?? false, reason: input.routing.decisionEvidence?.error },
141
135
  decision_evidence: input.routing.decisionEvidence,
136
+ decision_input: {
137
+ facts: input.decisionRequest.facts,
138
+ contract: {
139
+ type: input.decisionContract.type,
140
+ question: input.decisionContract.question,
141
+ choices: [...input.decisionContract.choices],
142
+ },
143
+ },
142
144
  worker_candidates: input.workers.map(workerAuditCandidate),
143
- worker_gate_results: workerGateResults,
145
+ worker_gate_results: input.workerGateEvaluations.map(item => ({
146
+ worker_id: item.workerId,
147
+ eligible: item.eligible,
148
+ rejection_reasons: [...item.rejectionReasons],
149
+ })),
144
150
  };
145
151
  }
@@ -2,28 +2,43 @@ import { chooseWithDecision } from './decision.js';
2
2
  import { makeExecutionDecisionAudit } from './decision-audit.js';
3
3
  import { discoverModelCandidates } from './model-routing.js';
4
4
  import { loadExecutionRouterRules, loadRoutingPolicy, policyAllows } from './routing-policy.js';
5
- import { DEFAULT_CHATGPT_PREFERRED_CONCURRENCY, eligibleWorkerCandidates } from './worker-routing.js';
5
+ import { DEFAULT_CHATGPT_PREFERRED_CONCURRENCY } from './worker-routing.js';
6
6
  function sameModelRoute(candidate, route) {
7
7
  return (!route.provider || candidate.provider === route.provider)
8
8
  && (!route.model || candidate.model === route.model)
9
9
  && (route.profile === undefined || candidate.profile === route.profile);
10
10
  }
11
- function modelUsable(candidate, constraints, excludedRoutes, policy) {
11
+ function workerGateReasons(worker, requirements) {
12
+ const reasons = [];
13
+ if (worker.availability === 'offline')
14
+ reasons.push('worker_offline');
15
+ if (requirements.runtime && worker.runtime !== requirements.runtime)
16
+ reasons.push('runtime_constraint');
17
+ if (worker.adapter === 'chatgpt' && worker.browserWorkspaceCapacity?.available === 0)
18
+ reasons.push('browser_workspace_exhausted');
19
+ return reasons;
20
+ }
21
+ function modelGateReasons(candidate, constraints, excludedRoutes, policy) {
22
+ const reasons = [];
12
23
  if (candidate.resourceHealthy === false)
13
- return false;
24
+ reasons.push('resource_unhealthy');
14
25
  if ((excludedRoutes ?? []).some(route => sameModelRoute(candidate, route)))
15
- return false;
16
- if (!policyAllows(candidate, policy.policy))
17
- return false;
26
+ reasons.push('prior_failed_route');
27
+ if (!policyAllows(candidate, policy.policy)) {
28
+ if (policy.policy.disabledProviders?.includes(candidate.provider))
29
+ reasons.push('policy_disabled_provider');
30
+ if (candidate.profile && policy.policy.disabledProfiles?.includes(candidate.profile))
31
+ reasons.push('policy_disabled_profile');
32
+ }
18
33
  if (constraints?.provider && candidate.provider !== constraints.provider)
19
- return false;
34
+ reasons.push('provider_constraint');
20
35
  if (constraints?.model && candidate.model !== constraints.model)
21
- return false;
36
+ reasons.push('model_constraint');
22
37
  if (constraints?.profile && candidate.profile !== constraints.profile)
23
- return false;
38
+ reasons.push('profile_constraint');
24
39
  if (constraints?.reasoning && candidate.reasoning && candidate.reasoning !== constraints.reasoning)
25
- return false;
26
- return true;
40
+ reasons.push('reasoning_constraint');
41
+ return reasons;
27
42
  }
28
43
  function routeId(worker, model) {
29
44
  return `${worker.workerId}::${model.id}`;
@@ -51,22 +66,27 @@ function workerSelection(worker, selectedBy, evidence) {
51
66
  };
52
67
  }
53
68
  export async function discoverExecutionRoutes(workers, requirements, options = {}) {
54
- const legalWorkers = eligibleWorkerCandidates(workers, requirements);
55
69
  const registered = options.registered ?? [];
56
70
  const policy = await loadRoutingPolicy(options.policyPath, options.rulesPath);
57
- const routes = [];
58
- for (const worker of legalWorkers) {
71
+ const workerGateEvaluations = workers.map(worker => {
72
+ const rejectionReasons = workerGateReasons(worker, requirements);
73
+ return { workerId: worker.workerId, eligible: rejectionReasons.length === 0, rejectionReasons };
74
+ });
75
+ const evaluations = [];
76
+ for (const worker of workers) {
77
+ const workerReasons = workerGateEvaluations.find(item => item.workerId === worker.workerId)?.rejectionReasons ?? [];
59
78
  const models = await discoverModelCandidates(worker, registered, options.usage, undefined, options.rulesPath);
60
79
  for (const model of models) {
61
- if (!modelUsable(model, options.routingConstraints, options.excludedRoutes, policy))
62
- continue;
63
- routes.push({ id: routeId(worker, model), worker, model });
80
+ const rejectionReasons = [...workerReasons, ...modelGateReasons(model, options.routingConstraints, options.excludedRoutes, policy)];
81
+ const route = { id: routeId(worker, model), worker, model };
82
+ evaluations.push({ route, eligible: rejectionReasons.length === 0, rejectionReasons });
64
83
  }
65
84
  }
66
- return { routes, policy };
85
+ const routes = evaluations.filter(item => item.eligible).map(item => item.route);
86
+ return { routes, evaluations, workerGateEvaluations, policy };
67
87
  }
68
88
  export async function routeExecution(workers, requirements, options = {}) {
69
- const { routes, policy } = await discoverExecutionRoutes(workers, requirements, options);
89
+ const { routes, evaluations, workerGateEvaluations, policy } = await discoverExecutionRoutes(workers, requirements, options);
70
90
  if (routes.length === 0)
71
91
  throw new Error('No executable worker+model route satisfies authoritative constraints');
72
92
  const fallback = fallbackRoute(routes);
@@ -118,11 +138,13 @@ export async function routeExecution(workers, requirements, options = {}) {
118
138
  },
119
139
  })),
120
140
  }));
121
- const choice = await chooseWithDecision(options.decisionEngine, options.decisionMode ?? 'off', { facts }, {
141
+ const decisionRequest = { facts };
142
+ const decisionContract = {
122
143
  type: 'choice',
123
144
  question: 'Choose the best complete execution route for this task. The listed candidates are already executable. Treat all non-impossibility signals as soft facts, follow free-text user guidance, and choose worker+model together in this one decision.',
124
145
  choices: routes.map(route => route.id),
125
- }, () => fallback.id);
146
+ };
147
+ const choice = await chooseWithDecision(options.decisionEngine, options.decisionMode ?? 'off', decisionRequest, decisionContract, () => fallback.id);
126
148
  const selected = routes.find(route => route.id === choice.choice) ?? fallback;
127
149
  const selectedBy = choice.evidence.engine && !choice.evidence.fallback ? 'execution-router:laya' : 'execution-router:fallback';
128
150
  const routing = {
@@ -138,7 +160,8 @@ export async function routeExecution(workers, requirements, options = {}) {
138
160
  task: options.auditTask,
139
161
  workers,
140
162
  requirements,
141
- routes,
163
+ evaluations,
164
+ workerGateEvaluations,
142
165
  selected,
143
166
  contract: {
144
167
  question: 'execution-route-selection',
@@ -150,6 +173,8 @@ export async function routeExecution(workers, requirements, options = {}) {
150
173
  },
151
174
  policy,
152
175
  routing,
176
+ decisionRequest,
177
+ decisionContract,
153
178
  });
154
179
  routing.decisionId = audit.decision_id;
155
180
  if (options.auditStore)
package/dist/pool.js CHANGED
@@ -108,9 +108,14 @@ export class RepositoryWorkerPool {
108
108
  }
109
109
  const seedKey = `${target.repository}#${target.prNumber}:${jobId}`;
110
110
  const mayConsumeSeed = signals.some(signal => signal.reason === 'target.request' || signal.reason.startsWith('github.webhook:'));
111
- const seed = mayConsumeSeed ? this.targetSeeds.get(seedKey) ?? null : null;
111
+ let seed = mayConsumeSeed ? this.targetSeeds.get(seedKey) ?? null : null;
112
112
  if (mayConsumeSeed)
113
113
  this.targetSeeds.delete(seedKey);
114
+ const needsFreshPullRequestFacts = signals.some(signal => !signal.event || signal.event.type === 'task.completed');
115
+ if (!seed && needsFreshPullRequestFacts && this.refreshWebhookSnapshot && !this.isRateLimited()) {
116
+ const trigger = `coordinator.${signals.map(signal => signal.reason).join('+') || 'reconcile'}`;
117
+ seed = (await this.refreshWebhookSnapshot(target.repository, target.prNumber, trigger)).seed;
118
+ }
114
119
  const store = this.makeStore(target.repository, target.prNumber, seed);
115
120
  const reconciler = this.makeReconciler(store, Math.max(1, this.concurrency));
116
121
  let shouldReconcile = false;
@@ -289,7 +289,11 @@ export class ProjectedDurableStore {
289
289
  });
290
290
  }
291
291
  async ensurePullRequestFacts() {
292
- const local = await localPullRequestFacts(this.relay, this.seededFacts);
292
+ const local = await this.relay.loadPullRequestFacts();
293
+ if (this.seededFacts && (!local || this.seededFacts.observedAt >= local.observedAt)) {
294
+ await this.relay.savePullRequestFacts(structuredClone(this.seededFacts));
295
+ return this.seededFacts;
296
+ }
293
297
  if (local)
294
298
  return local;
295
299
  if (!this.github.pullRequestState || !this.github.pullRequestHead)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.12",
3
+ "version": "2.0.14",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {