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.
- package/README.md +4 -2
- package/dist/cli-help.js +15 -1
- package/dist/cli-projection.js +61 -0
- package/dist/cli.js +6 -0
- package/dist/dashboard-html.js +2 -2
- package/dist/decision-audit.js +1 -1
- package/dist/execution-routing.js +117 -88
- package/dist/projected-store.js +67 -8
- package/dist/projection-outbox.js +268 -0
- package/dist/relay-store.js +26 -148
- package/dist/relayd.js +23 -3
- package/dist/router-evals.js +2 -2
- package/dist/router-rules.js +101 -0
- package/dist/routing-policy.js +4 -3
- package/dist/store-github.js +6 -5
- package/dist/store-task-definition.js +63 -0
- package/dist/store.js +4 -4
- package/dist/workflow/projection-conflict-v2.js +26 -0
- package/package.json +1 -1
- package/workflow/projection-conflict.mmd +11 -0
|
@@ -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
|
+
}
|
package/dist/routing-policy.js
CHANGED
|
@@ -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');
|
package/dist/store-github.js
CHANGED
|
@@ -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 ||
|
|
326
|
-
throw new
|
|
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
|
-
|
|
361
|
-
|
|
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 &&
|
|
98
|
+
if (current && samePlanDefinition(current, plan))
|
|
99
99
|
return;
|
|
100
|
-
throw new
|
|
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
|
|
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
|
@@ -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
|