agent-relay-server 0.94.5 → 0.94.6
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/docs/openapi.json +1 -1
- package/package.json +1 -1
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/agent-busy-health.ts +12 -1
- package/src/maintenance/jobs.ts +0 -1
- package/src/notification-router.ts +4 -0
- package/src/notification-rules.ts +87 -6
- package/src/notification-types.ts +12 -0
package/docs/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Relay API",
|
|
5
|
-
"version": "0.94.
|
|
5
|
+
"version": "0.94.6",
|
|
6
6
|
"description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
|
|
7
7
|
"license": {
|
|
8
8
|
"name": "MIT",
|
package/package.json
CHANGED
package/src/agent-busy-health.ts
CHANGED
|
@@ -38,6 +38,13 @@ function activeWork(agent: AgentCard): ActiveWorkLike[] {
|
|
|
38
38
|
return Array.isArray(agent.meta?.activeWork) ? agent.meta.activeWork as ActiveWorkLike[] : [];
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function hasLiveToolOrSubprocessWork(works: ActiveWorkLike[]): boolean {
|
|
42
|
+
return works.some((work) => {
|
|
43
|
+
const kind = stringValue(work.kind);
|
|
44
|
+
return Boolean(kind && kind !== "provider-turn");
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
41
48
|
function thresholdFor(kind: BusyHealthKind): number {
|
|
42
49
|
if (kind === "startup") return STUCK_BUSY_STARTUP_MS;
|
|
43
50
|
if (kind === "reconnect") return STUCK_BUSY_RECONNECT_MS;
|
|
@@ -70,13 +77,17 @@ function classifyBusy(agent: AgentCard, now: number): { kind: BusyHealthKind; wo
|
|
|
70
77
|
export function deriveAgentBusyHealth(agent: AgentCard, now: number = Date.now()): AgentBusyHealth | undefined {
|
|
71
78
|
const classified = classifyBusy(agent, now);
|
|
72
79
|
if (!classified) return undefined;
|
|
80
|
+
const works = activeWork(agent);
|
|
73
81
|
const thresholdMs = thresholdFor(classified.kind);
|
|
74
82
|
const activeWorkStartedAt = Math.max(0, classified.startedAt);
|
|
75
83
|
const activeWorkAgeMs = Math.max(0, now - activeWorkStartedAt);
|
|
76
84
|
const lastProviderEventAt = numberValue(agent.meta?.lastProviderEventAt);
|
|
77
85
|
const lastProgressAt = lastProviderEventAt ?? activeWorkStartedAt;
|
|
78
86
|
const staleForMs = Math.max(0, now - lastProgressAt);
|
|
79
|
-
const stuck =
|
|
87
|
+
const stuck =
|
|
88
|
+
!hasLiveToolOrSubprocessWork(works) &&
|
|
89
|
+
activeWorkAgeMs >= thresholdMs &&
|
|
90
|
+
staleForMs >= thresholdMs;
|
|
80
91
|
const work = classified.work;
|
|
81
92
|
return {
|
|
82
93
|
status: stuck ? "stuck-busy" : "ok",
|
package/src/maintenance/jobs.ts
CHANGED
|
@@ -120,7 +120,6 @@ export function runStuckBusyWatchdog(input: { now?: number } = {}): Record<strin
|
|
|
120
120
|
const messages = routeNotification({
|
|
121
121
|
type: "agent.stuck_busy",
|
|
122
122
|
scope: { agentId: agent.id, parent: agent.spawnedBy, teamId: agent.teamId },
|
|
123
|
-
recipients: ["user"],
|
|
124
123
|
message: {
|
|
125
124
|
subject: "Agent stuck busy",
|
|
126
125
|
body: stuckBusyBody(agent.id, health),
|
|
@@ -203,6 +203,10 @@ function routingReason(
|
|
|
203
203
|
): string {
|
|
204
204
|
if (!settingsEnabled) return "notifications are disabled for this recipient by settings";
|
|
205
205
|
if (matchedRule === "author") return "you are the author";
|
|
206
|
+
if (matchedRule === "targeted-at-user") return "you are directly targeted";
|
|
207
|
+
if (matchedRule === "spawned-by-user") return "you directly spawned this agent";
|
|
208
|
+
if (matchedRule === "spawn-parent-of") return "you are the subject agent's spawn parent";
|
|
209
|
+
if (matchedRule === "thread-participant") return "you are a participant in this thread";
|
|
206
210
|
if (matchedRule === "spawn-parent") return "you are the spawn parent";
|
|
207
211
|
if (matchedRule === "subject-agent") return "you are the notification subject";
|
|
208
212
|
if (matchedRule === "same-team") return scope.teamId ? `same team ${scope.teamId}` : "same team";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AgentCard } from "./types";
|
|
2
|
-
import {
|
|
2
|
+
import { canonicalHumanAgentId, isHumanAgentId } from "agent-relay-sdk";
|
|
3
|
+
import { getAgent, getThread, listAgents } from "./db";
|
|
3
4
|
import { isAgentOnline } from "./agent-ref";
|
|
4
5
|
import { notificationEnabledFor } from "./notification-settings-store";
|
|
5
6
|
import type { NotificationType, NotificationScope } from "./notification-types";
|
|
@@ -40,6 +41,31 @@ interface AudienceMember {
|
|
|
40
41
|
matchedRule: string;
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
function sameAddress(a: string | undefined, b: string | undefined): boolean {
|
|
45
|
+
if (!a || !b) return false;
|
|
46
|
+
if (isHumanAgentId(a) || isHumanAgentId(b)) return canonicalHumanAgentId(a) === canonicalHumanAgentId(b);
|
|
47
|
+
return a === b;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function subjectParent(scope: NotificationScope): string | undefined {
|
|
51
|
+
if (scope.parent) return scope.parent;
|
|
52
|
+
return scope.agentId ? getAgent(scope.agentId)?.spawnedBy : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function threadParticipantIds(scope: NotificationScope): Set<string> {
|
|
56
|
+
const ids = new Set<string>();
|
|
57
|
+
for (const id of scope.participants ?? []) {
|
|
58
|
+
if (id.trim()) ids.add(id);
|
|
59
|
+
}
|
|
60
|
+
if (typeof scope.threadId === "number" && Number.isSafeInteger(scope.threadId) && scope.threadId > 0) {
|
|
61
|
+
for (const message of getThread(scope.threadId)) {
|
|
62
|
+
ids.add(message.from);
|
|
63
|
+
ids.add(message.to);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return ids;
|
|
67
|
+
}
|
|
68
|
+
|
|
43
69
|
/**
|
|
44
70
|
* Predicate library — the reusable building blocks each type composes its default audience from.
|
|
45
71
|
* Each factory returns a single-concern rule. `deliver` includes the agent; `abstain` leaves the
|
|
@@ -47,6 +73,38 @@ interface AudienceMember {
|
|
|
47
73
|
* shape #443 builds on). Compose them ordered: later rules override earlier (see {@link evaluateAudience}).
|
|
48
74
|
*/
|
|
49
75
|
export const audiencePredicates = {
|
|
76
|
+
/** A human directly targeted by the message/chat event. */
|
|
77
|
+
targetedAtUser: (): NotificationRule => ({
|
|
78
|
+
name: "targeted-at-user",
|
|
79
|
+
evaluate: (a, { scope }) => {
|
|
80
|
+
if (!isHumanAgentId(a.id)) return "abstain";
|
|
81
|
+
const target = scope.to ?? scope.target;
|
|
82
|
+
return sameAddress(a.id, target) ? "deliver" : "abstain";
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
/** A human directly spawned the subject agent. Depth-1 only; grandchildren abstain. */
|
|
86
|
+
spawnedByUser: (): NotificationRule => ({
|
|
87
|
+
name: "spawned-by-user",
|
|
88
|
+
evaluate: (a, { scope }) => {
|
|
89
|
+
if (!isHumanAgentId(a.id)) return "abstain";
|
|
90
|
+
return sameAddress(a.id, subjectParent(scope)) ? "deliver" : "abstain";
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
/** The subject agent's spawn-parent, deriving it from `scope.agentId` when needed. */
|
|
94
|
+
spawnParentOf: (): NotificationRule => ({
|
|
95
|
+
name: "spawn-parent-of",
|
|
96
|
+
evaluate: (a, { scope }) => (sameAddress(a.id, subjectParent(scope)) ? "deliver" : "abstain"),
|
|
97
|
+
}),
|
|
98
|
+
/** Any participant in the message/chat thread. */
|
|
99
|
+
threadParticipant: (): NotificationRule => ({
|
|
100
|
+
name: "thread-participant",
|
|
101
|
+
evaluate: (a, { scope }) => {
|
|
102
|
+
for (const participant of threadParticipantIds(scope)) {
|
|
103
|
+
if (sameAddress(a.id, participant)) return "deliver";
|
|
104
|
+
}
|
|
105
|
+
return "abstain";
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
50
108
|
/** The agent whose work the notification is about — delivered even when offline (store-ahead #234). */
|
|
51
109
|
author: (): NotificationRule => ({
|
|
52
110
|
name: "author",
|
|
@@ -96,14 +154,15 @@ export const audiencePredicates = {
|
|
|
96
154
|
} as const;
|
|
97
155
|
|
|
98
156
|
/**
|
|
99
|
-
* The candidate pool for rule evaluation: every registered agent EXCEPT
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* offline target; per-rule `onlineOnly` gates
|
|
157
|
+
* The candidate pool for rule evaluation: every registered agent EXCEPT `system` and channel agents,
|
|
158
|
+
* which are never wake-notification recipients. Human sinks stay in the pool so human-surface rules
|
|
159
|
+
* can decide when `user` belongs in an audience. Offline agents stay IN deliberately — a store-ahead
|
|
160
|
+
* rule (author/spawn-parent) must be able to select an offline target; per-rule `onlineOnly` gates
|
|
161
|
+
* the cases that need liveness (e.g. on-repo-root fan-out).
|
|
103
162
|
*/
|
|
104
163
|
export function audienceCandidates(): AgentCard[] {
|
|
105
164
|
return listAgents().filter(
|
|
106
|
-
(a) => a.id !== "system" && a.
|
|
165
|
+
(a) => a.id !== "system" && a.kind !== "system" && a.kind !== "channel" && a.meta?.kind !== "channel",
|
|
107
166
|
);
|
|
108
167
|
}
|
|
109
168
|
|
|
@@ -190,3 +249,25 @@ export function __resetAudienceRules(): void {
|
|
|
190
249
|
for (const [type, rules] of defaultAudienceRegistry) audienceRegistry.set(type, rules);
|
|
191
250
|
subscriptionRules = [];
|
|
192
251
|
}
|
|
252
|
+
|
|
253
|
+
const SPAWN_LIFECYCLE_AUDIENCE_TYPES = [
|
|
254
|
+
"agent.ready",
|
|
255
|
+
"agent.exited",
|
|
256
|
+
"agent.spawn_failed",
|
|
257
|
+
"agent.spawn_recovered",
|
|
258
|
+
] as const satisfies readonly NotificationType[];
|
|
259
|
+
|
|
260
|
+
for (const type of SPAWN_LIFECYCLE_AUDIENCE_TYPES) {
|
|
261
|
+
registerDefaultAudienceRules(type, [audiencePredicates.spawnParentOf(), audiencePredicates.spawnedByUser()]);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
registerDefaultAudienceRules("agent.stuck_busy", [
|
|
265
|
+
audiencePredicates.spawnParentOf(),
|
|
266
|
+
audiencePredicates.spawnedByUser(),
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
registerDefaultAudienceRules("message.new", [
|
|
270
|
+
audiencePredicates.targetedAtUser(),
|
|
271
|
+
audiencePredicates.spawnedByUser(),
|
|
272
|
+
audiencePredicates.threadParticipant(),
|
|
273
|
+
]);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export type NotificationType =
|
|
2
|
+
| "message.new"
|
|
2
3
|
| "landed-failure"
|
|
3
4
|
| "branch.landed"
|
|
4
5
|
| "agent.ready"
|
|
@@ -31,6 +32,16 @@ export type NotificationType =
|
|
|
31
32
|
* All optional because each emitter fills the fields it has in scope at the call site.
|
|
32
33
|
*/
|
|
33
34
|
export interface NotificationScope {
|
|
35
|
+
/** Message sender for message/chat audience evaluation. */
|
|
36
|
+
from?: string;
|
|
37
|
+
/** Message target for message/chat audience evaluation. */
|
|
38
|
+
to?: string;
|
|
39
|
+
/** Alias for `to` used by audience preview clients that describe the primary target. */
|
|
40
|
+
target?: string;
|
|
41
|
+
/** Message thread id for thread participant audience evaluation. */
|
|
42
|
+
threadId?: number;
|
|
43
|
+
/** Pre-resolved thread participants when the caller already has the thread membership. */
|
|
44
|
+
participants?: string[];
|
|
34
45
|
/** The agent whose work the notification concerns (branch author, workspace owner). */
|
|
35
46
|
author?: string;
|
|
36
47
|
/** The agent the notification is primarily about (lifecycle subject, advisory target). */
|
|
@@ -56,6 +67,7 @@ const MINUTES = 60 * 1000;
|
|
|
56
67
|
* obligation is `null` (never auto-expires). Only genuinely now-or-never pings get a short default.
|
|
57
68
|
*/
|
|
58
69
|
export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
|
|
70
|
+
"message.new": null,
|
|
59
71
|
"landed-failure": null,
|
|
60
72
|
"branch.landed": null,
|
|
61
73
|
"agent.ready": null,
|