agents-relay 2.0.31 → 2.0.33

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.
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Router Rules free-text parsing.
3
+ *
4
+ * The Execution Router rules file carries two explicit, user-authored sections:
5
+ *
6
+ * ```text
7
+ * [choices]
8
+ * openai: gpt-5.6-luna high, sol medium
9
+ * zai: glm-5.3-flash high
10
+ *
11
+ * [routing-instruction]
12
+ * Prefer zai until its quota resets; otherwise use the openai default.
13
+ * ```
14
+ *
15
+ * `[choices]` is an explicit, additive list of provider/model/reasoning routes.
16
+ * `[routing-instruction]` is opaque guidance forwarded to Laya verbatim: the
17
+ * exact section body is preserved byte-for-byte (leading and trailing
18
+ * whitespace, blank lines, and line endings included) so Relay never rewrites or
19
+ * normalizes user intent. Everything outside these sections is ignored so legacy
20
+ * free-text files remain valid and Relay never has to guess at intent.
21
+ */
22
+ const SECTION_CHOICES = 'choices';
23
+ const SECTION_INSTRUCTION = 'routing-instruction';
24
+ const SECTION_HEADER = /^[ \t]*\[([a-z][a-z0-9-]*)\][ \t]*\r?$/gim;
25
+ function sectionOf(line) {
26
+ const match = line.match(/^\s*\[([a-z][a-z0-9-]*)\]\s*$/i);
27
+ return match ? match[1].toLowerCase() : null;
28
+ }
29
+ function choiceKey(choice) {
30
+ return [choice.provider, choice.model, choice.reasoning ?? ''].join('\u0000');
31
+ }
32
+ /**
33
+ * Return the raw body of the first section named `name`, exactly as authored.
34
+ *
35
+ * The body begins after the line terminator that ends the section header line
36
+ * (the terminator belongs to the header) and ends at the start of the next
37
+ * section header line, or at end of input. Nothing inside the body is trimmed or
38
+ * normalized: whitespace, blank lines, and line endings are part of the
39
+ * forwarded instruction.
40
+ */
41
+ function rawSectionBody(text, name) {
42
+ const headers = [];
43
+ for (const match of text.matchAll(SECTION_HEADER)) {
44
+ const lineStart = match.index ?? 0;
45
+ headers.push({ name: match[1].toLowerCase(), lineStart, lineEnd: lineStart + match[0].length });
46
+ }
47
+ const index = headers.findIndex(header => header.name === name);
48
+ if (index < 0)
49
+ return null;
50
+ let bodyStart = headers[index].lineEnd;
51
+ if (text[bodyStart] === '\r')
52
+ bodyStart += 1;
53
+ if (text[bodyStart] === '\n')
54
+ bodyStart += 1;
55
+ const bodyEnd = index + 1 < headers.length ? headers[index + 1].lineStart : text.length;
56
+ return text.slice(bodyStart, bodyEnd);
57
+ }
58
+ /**
59
+ * Parse explicit router choices and the routing instruction from Router Rules.
60
+ *
61
+ * `[choices]` entries are deduplicated deterministically (first occurrence
62
+ * wins). The `[routing-instruction]` body is captured verbatim.
63
+ */
64
+ export function parseRouterRules(text) {
65
+ const choices = [];
66
+ const seen = new Set();
67
+ let section = null;
68
+ for (const rawLine of text.split(/\r?\n/)) {
69
+ const header = sectionOf(rawLine);
70
+ if (header !== null) {
71
+ section = header;
72
+ continue;
73
+ }
74
+ if (section === SECTION_INSTRUCTION)
75
+ continue;
76
+ if (section !== SECTION_CHOICES)
77
+ continue;
78
+ if (!rawLine.trim() || rawLine.trim().startsWith('#'))
79
+ continue;
80
+ const match = rawLine.match(/^\s*([A-Za-z0-9._-]+)\s*:\s*(.+)$/);
81
+ if (!match)
82
+ continue;
83
+ const provider = match[1].trim();
84
+ for (const segment of match[2].split(',')) {
85
+ const tokens = segment.trim().split(/\s+/).filter(Boolean);
86
+ if (tokens.length === 0)
87
+ continue;
88
+ const choice = {
89
+ provider,
90
+ model: tokens[0],
91
+ ...(tokens[1] ? { reasoning: tokens[1].toLowerCase() } : {}),
92
+ };
93
+ const key = choiceKey(choice);
94
+ if (seen.has(key))
95
+ continue;
96
+ seen.add(key);
97
+ choices.push(choice);
98
+ }
99
+ }
100
+ return { choices, routingInstruction: rawSectionBody(text, SECTION_INSTRUCTION) ?? '' };
101
+ }
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
+ import { parseRouterRules } from './router-rules.js';
5
6
  const EMPTY_POLICY = {};
6
7
  function safePolicy(value) {
7
8
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -34,16 +35,16 @@ export function policyPath(explicit) {
34
35
  async function loadRouterRules(path) {
35
36
  try {
36
37
  const text = await readFile(path, 'utf8');
37
- return { path, hash: createHash('sha256').update(text).digest('hex'), text };
38
+ return { path, hash: createHash('sha256').update(text).digest('hex'), text, parsed: parseRouterRules(text) };
38
39
  }
39
40
  catch {
40
- return { path, hash: 'none', text: '' };
41
+ return { path, hash: 'none', text: '', parsed: parseRouterRules('') };
41
42
  }
42
43
  }
43
44
  async function saveRouterRules(path, text) {
44
45
  await mkdir(dirname(path), { recursive: true });
45
46
  await writeFile(path, text, 'utf8');
46
- return { path, hash: createHash('sha256').update(text).digest('hex'), text };
47
+ return { path, hash: createHash('sha256').update(text).digest('hex'), text, parsed: parseRouterRules(text) };
47
48
  }
48
49
  export function executionRouterRulesPath(explicit) {
49
50
  return explicit ?? process.env.AGENTS_RELAY_EXECUTION_ROUTER_RULES ?? join(homedir(), '.agents-relay', 'execution-router-rules.md');
@@ -1,5 +1,5 @@
1
1
  import { parseAgent, parseJob, parsePlan, parseTask, renderAgentComment, renderJobBody, renderPlanComment, renderTaskComment, JOB_MARKER } from './markers.js';
2
- import { isTaskForJob, sameTaskDefinition } from './store-task-definition.js';
2
+ import { DurableDefinitionConflictError, isTaskForJob, planDefinitionDivergence, samePlanDefinition, sameTaskDefinition, taskDefinitionDivergence } from './store-task-definition.js';
3
3
  import { registerBlockerDependency, resolveBlockerDependencies } from './blockers.js';
4
4
  const TASK_WRITE_OVERLAY = new Map();
5
5
  const PLAN_WRITE_OVERLAY = new Map();
@@ -322,8 +322,8 @@ export class GitHubStore {
322
322
  return;
323
323
  }
324
324
  const existing = parsePlan(matches[0].body);
325
- if (!existing || JSON.stringify({ ...existing, planUrl: undefined }) !== JSON.stringify({ ...plan, planUrl: undefined }))
326
- throw new Error(`Plan ${plan.id} already exists with a different definition`);
325
+ if (!existing || !samePlanDefinition(existing, plan))
326
+ throw new DurableDefinitionConflictError('plan', 'append_plan', plan.jobId, plan.id, plan, existing ?? null, existing ? planDefinitionDivergence(existing, plan) : []);
327
327
  for (const duplicate of matches.slice(1))
328
328
  await this.deleteComment(duplicate);
329
329
  }
@@ -357,8 +357,9 @@ export class GitHubStore {
357
357
  return;
358
358
  }
359
359
  const parsed = matches.map(comment => ({ comment, task: parseTask(comment.body) }));
360
- if (parsed.some(item => !sameTaskDefinition(item.task, task)))
361
- throw new Error(`Task ${task.id} already exists with a different definition`);
360
+ const divergent = parsed.find(item => !sameTaskDefinition(item.task, task));
361
+ if (divergent)
362
+ throw new DurableDefinitionConflictError('task', 'append_task', task.jobId ?? '', task.id, task, divergent.task, taskDefinitionDivergence(divergent.task, task));
362
363
  for (const duplicate of parsed.slice(1))
363
364
  await this.deleteComment(duplicate.comment);
364
365
  }
@@ -1,3 +1,37 @@
1
+ /**
2
+ * Deterministic conflict between two different definitions that claim the same durable id.
3
+ *
4
+ * This is not a transient projection failure: the same id is already occupied by a
5
+ * different immutable definition, so retrying the same append can never succeed.
6
+ * Callers classify it once, stop consuming retry budget, and record diagnostic evidence.
7
+ */
8
+ export class DurableDefinitionConflictError extends Error {
9
+ kind;
10
+ operation;
11
+ jobId;
12
+ subjectId;
13
+ local;
14
+ remote;
15
+ divergingFields;
16
+ deterministic = true;
17
+ constructor(kind, operation, jobId, subjectId, local, remote, divergingFields) {
18
+ super(kind === 'task'
19
+ ? `Task ${subjectId} already exists with a different definition`
20
+ : `Plan ${subjectId} already exists with a different definition`);
21
+ this.kind = kind;
22
+ this.operation = operation;
23
+ this.jobId = jobId;
24
+ this.subjectId = subjectId;
25
+ this.local = local;
26
+ this.remote = remote;
27
+ this.divergingFields = divergingFields;
28
+ this.name = 'DurableDefinitionConflictError';
29
+ }
30
+ }
31
+ export function isDurableDefinitionConflict(error) {
32
+ return error instanceof DurableDefinitionConflictError
33
+ || (typeof error === 'object' && error !== null && error.name === 'DurableDefinitionConflictError');
34
+ }
1
35
  function immutableTaskDefinition(task) {
2
36
  const routing = task.routing ? {
3
37
  provider: task.routing.provider, model: task.routing.model, profile: task.routing.profile,
@@ -12,6 +46,35 @@ function immutableTaskDefinition(task) {
12
46
  export function sameTaskDefinition(left, right) {
13
47
  return JSON.stringify(immutableTaskDefinition(left)) === JSON.stringify(immutableTaskDefinition(right));
14
48
  }
49
+ /** Plan identity ignores the resolved comment URL, which is projection metadata rather than definition. */
50
+ export function planDefinition(plan) {
51
+ const { planUrl: _planUrl, ...rest } = plan;
52
+ return rest;
53
+ }
54
+ export function samePlanDefinition(left, right) {
55
+ return JSON.stringify(planDefinition(left)) === JSON.stringify(planDefinition(right));
56
+ }
57
+ function observableDefinition(kind, definition) {
58
+ if (typeof definition !== 'object' || definition === null)
59
+ return {};
60
+ if (kind === 'task')
61
+ return immutableTaskDefinition(definition);
62
+ const { planUrl: _planUrl, ...rest } = definition;
63
+ return rest;
64
+ }
65
+ /** Top-level field names whose definition values diverge; enough to diagnose a conflicted id. */
66
+ export function divergingDefinitionFields(kind, left, right) {
67
+ const observed = observableDefinition(kind, left);
68
+ const other = observableDefinition(kind, right);
69
+ const keys = [...new Set([...Object.keys(observed), ...Object.keys(other)])].sort();
70
+ return keys.filter(key => JSON.stringify(observed[key]) !== JSON.stringify(other[key]));
71
+ }
72
+ export function taskDefinitionDivergence(left, right) {
73
+ return divergingDefinitionFields('task', left, right);
74
+ }
75
+ export function planDefinitionDivergence(left, right) {
76
+ return divergingDefinitionFields('plan', left, right);
77
+ }
15
78
  export function isTaskForJob(task, jobId, soleTrustedJob) {
16
79
  if (task.jobId === jobId)
17
80
  return true;
package/dist/store.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { parseAgent, parseJob, parsePlan, parseTask, renderAgentComment, renderJobComment, renderPlanComment, renderTaskComment, AGENT_MARKER, JOB_MARKER, PLAN_MARKER, TASK_MARKER } from './markers.js';
2
- import { isTaskForJob, sameTaskDefinition } from './store-task-definition.js';
2
+ import { DurableDefinitionConflictError, isTaskForJob, planDefinitionDivergence, samePlanDefinition, sameTaskDefinition, taskDefinitionDivergence } from './store-task-definition.js';
3
3
  import { parsePackagePublicationMarker } from './github-webhook.js';
4
4
  function publicationFromComments(rows, trustedAuthors) {
5
5
  const allowed = rows.filter(item => {
@@ -95,9 +95,9 @@ export class InMemoryStore {
95
95
  async saveJob(job) { this.values.set(`${JOB_MARKER}:${job.id}`, renderJobComment(job)); }
96
96
  async appendPlan(plan) { const key = `${PLAN_MARKER}:${plan.jobId}:${plan.id}`; const existing = this.values.get(key); if (existing) {
97
97
  const current = parsePlan(existing);
98
- if (current && JSON.stringify(current) === JSON.stringify(plan))
98
+ if (current && samePlanDefinition(current, plan))
99
99
  return;
100
- throw new Error(`Plan ${plan.id} already exists with a different definition`);
100
+ throw new DurableDefinitionConflictError('plan', 'append_plan', plan.jobId, plan.id, plan, current ?? null, current ? planDefinitionDivergence(current, plan) : []);
101
101
  } this.values.set(key, renderPlanComment(plan)); }
102
102
  async savePlan(plan) { this.values.set(`${PLAN_MARKER}:${plan.jobId}:${plan.id}`, renderPlanComment(plan)); }
103
103
  async appendTask(task) {
@@ -107,7 +107,7 @@ export class InMemoryStore {
107
107
  const current = parseTask(existing);
108
108
  if (current && sameTaskDefinition(current, task))
109
109
  return;
110
- throw new Error(`Task ${task.id} already exists with a different definition`);
110
+ throw new DurableDefinitionConflictError('task', 'append_task', task.jobId ?? '', task.id, task, current ?? null, current ? taskDefinitionDivergence(current, task) : []);
111
111
  }
112
112
  this.values.set(key, renderTaskComment(task));
113
113
  }
@@ -0,0 +1,26 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { GraphRuntime } from './graph-v2.js';
3
+ import { dispatchWorkflowGraphSync } from './dispatcher.js';
4
+ const source = readFileSync(new URL('../../workflow/projection-conflict.mmd', import.meta.url), 'utf8');
5
+ const boolGuard = (name) => (facts) => facts.durableFacts[name] === true;
6
+ export const projectionConflictV2 = new GraphRuntime(source, {
7
+ states: new Set(['classify', 'retry', 'conflict']),
8
+ guards: { deterministicDefinitionConflict: boolGuard('deterministicDefinitionConflict') },
9
+ actions: {},
10
+ events: { 'projection.failed': { id: 'projection.failed' } },
11
+ });
12
+ /**
13
+ * Core projection-failure disposition policy.
14
+ *
15
+ * A deterministic same-id/different-definition conflict stops consuming retry budget;
16
+ * any other failure keeps the normal backoff/retry path. This branch is owned by the
17
+ * canonical Mermaid graph, not by a procedural `if` in the projection writer.
18
+ */
19
+ export function decideProjectionFailure(facts) {
20
+ return dispatchWorkflowGraphSync(projectionConflictV2, {
21
+ initialState: 'classify',
22
+ input: { transport: 'event', id: 'projection.failed', payload: {} },
23
+ durableFacts: facts,
24
+ maxSteps: 1,
25
+ });
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.31",
3
+ "version": "2.0.33",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,11 @@
1
+ flowchart LR
2
+ %% relay: initial classify
3
+ classify["state: classify"]
4
+ retry["state: retry"]
5
+ conflict["state: conflict"]
6
+
7
+ deterministic["guard: deterministicDefinitionConflict"]
8
+
9
+ classify -->|on: projection.failed| deterministic
10
+ deterministic -->|true| conflict
11
+ deterministic -->|false| retry