agent-relay-server 0.79.0 → 0.80.0

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 CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Relay API",
5
- "version": "0.79.0",
5
+ "version": "0.80.0",
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",
@@ -3332,6 +3332,60 @@
3332
3332
  ]
3333
3333
  }
3334
3334
  },
3335
+ "/api/teams/config": {
3336
+ "get": {
3337
+ "operationId": "getTeamsConfigRoute",
3338
+ "summary": "Get Teams Config",
3339
+ "tags": [
3340
+ "Other"
3341
+ ],
3342
+ "responses": {
3343
+ "200": {
3344
+ "description": "Success",
3345
+ "content": {
3346
+ "application/json": {}
3347
+ }
3348
+ }
3349
+ },
3350
+ "security": [
3351
+ {
3352
+ "bearerAuth": []
3353
+ },
3354
+ {
3355
+ "tokenHeader": []
3356
+ },
3357
+ {
3358
+ "tokenQuery": []
3359
+ }
3360
+ ]
3361
+ },
3362
+ "put": {
3363
+ "operationId": "putTeamsConfigRoute",
3364
+ "summary": "Put Teams Config",
3365
+ "tags": [
3366
+ "Other"
3367
+ ],
3368
+ "responses": {
3369
+ "200": {
3370
+ "description": "Success",
3371
+ "content": {
3372
+ "application/json": {}
3373
+ }
3374
+ }
3375
+ },
3376
+ "security": [
3377
+ {
3378
+ "bearerAuth": []
3379
+ },
3380
+ {
3381
+ "tokenHeader": []
3382
+ },
3383
+ {
3384
+ "tokenQuery": []
3385
+ }
3386
+ ]
3387
+ }
3388
+ },
3335
3389
  "/api/teams": {
3336
3390
  "get": {
3337
3391
  "operationId": "getTeamsRoute",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.79.0",
3
+ "version": "0.80.0",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "CONTRIBUTING.md"
34
34
  ],
35
35
  "dependencies": {
36
- "agent-relay-sdk": "0.2.59",
36
+ "agent-relay-sdk": "0.2.60",
37
37
  "ajv": "^8.20.0"
38
38
  },
39
39
  "scripts": {
package/src/db/teams.ts CHANGED
@@ -3,6 +3,7 @@ import { getAgent, setAgentTeam } from "./agents.ts";
3
3
  import { getDb, ValidationError } from "./connection.ts";
4
4
  import { rowToAgent } from "./mappers.ts";
5
5
  import { unpromotedBlackboardEntries, type UnpromotedBlackboardSummary } from "./project-entries.ts";
6
+ import { isProviderBlocked } from "../provider-state.ts";
6
7
  import type { AgentCard, Team } from "../types";
7
8
 
8
9
  interface TeamRow {
@@ -72,6 +73,11 @@ export interface TeamOutcomeAggregates {
72
73
  recent: TeamOutcomeRecent[];
73
74
  }
74
75
 
76
+ interface TeamBusyState {
77
+ busyMemberIds: string[];
78
+ lastBusyAt?: number;
79
+ }
80
+
75
81
  export function teamId(): string {
76
82
  return `team_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
77
83
  }
@@ -132,6 +138,18 @@ export function countLiveTeamMembers(teamId: string, cutoff: number): number {
132
138
  return row.n;
133
139
  }
134
140
 
141
+ export function getTeamBusyState(teamId: string, cutoff: number): TeamBusyState {
142
+ const rows = (getDb().query(
143
+ "SELECT * FROM agents WHERE team_id = ? AND status = 'busy' AND last_seen > ? ORDER BY last_seen DESC, id ASC",
144
+ ).all(teamId, cutoff) as any[])
145
+ .map(rowToAgent)
146
+ .filter((agent) => !isProviderBlocked(agent));
147
+ return {
148
+ busyMemberIds: rows.map((row) => row.id),
149
+ ...(rows[0] ? { lastBusyAt: rows[0].lastSeen } : {}),
150
+ };
151
+ }
152
+
135
153
  export function setTeamZeroMembersSince(teamId: string, ts: number): boolean {
136
154
  return getDb().query("UPDATE teams SET zero_members_since = ?, updated_at = ? WHERE id = ?").run(ts, ts, teamId).changes > 0;
137
155
  }
@@ -51,7 +51,7 @@ import {
51
51
  } from "./constants";
52
52
  import { emitCommand } from "./events";
53
53
  import { reapOrphanedSessions } from "./orphaned-sessions";
54
- import { reapZeroMemberTeams } from "./teams";
54
+ import { reapZeroMemberTeams, runTeamIdleWatchdog } from "./teams";
55
55
  import { scanWorkspaceConflicts, workspaceGC } from "./workspaces";
56
56
  import type { MaintenanceJobDefinition } from "./types";
57
57
 
@@ -208,6 +208,14 @@ export const definitions: MaintenanceJobDefinition[] = [
208
208
  runOnStart: false,
209
209
  handler: reapZeroMemberTeams,
210
210
  },
211
+ {
212
+ id: "team-idle-watchdog",
213
+ title: "Team idle watchdog",
214
+ description: "Gently ping a team's current coordinator once when the team has had no busy members for the configured threshold.",
215
+ intervalMs: REAP_INTERVAL_MS,
216
+ runOnStart: false,
217
+ handler: runTeamIdleWatchdog,
218
+ },
211
219
  {
212
220
  id: "orchestrator-reaper",
213
221
  title: "Orchestrator reaper",
@@ -2,12 +2,134 @@ import {
2
2
  clearTeamZeroMembersSince,
3
3
  countLiveTeamMembers,
4
4
  createActivityEvent,
5
+ getTeamBusyState,
6
+ getTeamRoster,
5
7
  listActiveTeams,
6
8
  setTeamZeroMembersSince,
7
9
  } from "../db";
8
10
  import { STALE_TTL_MS } from "../config";
11
+ import { agentProviderState } from "../provider-state";
9
12
  import { dissolveTeamById } from "../services/team";
13
+ import { getTeamsConfig } from "../teams-config-store";
10
14
  import { teamReapGraceMs } from "./constants";
15
+ import { notifyTarget } from "./events";
16
+
17
+ interface TeamIdleTrackerEntry {
18
+ firstNoBusyAt: number;
19
+ pinged: boolean;
20
+ }
21
+
22
+ const teamIdleTracker = new Map<string, TeamIdleTrackerEntry>();
23
+
24
+ export function resetTeamIdleWatchdogForTests(): void {
25
+ teamIdleTracker.clear();
26
+ }
27
+
28
+ function teamIdlePayload(teamId: string, coordinatorId: string, thresholdMinutes: number, idleForMs: number, firstNoBusyAt: number) {
29
+ const roster = getTeamRoster(teamId).map((member) => {
30
+ const providerState = agentProviderState(member);
31
+ return {
32
+ id: member.id,
33
+ name: member.name,
34
+ label: member.label,
35
+ status: member.status,
36
+ ready: member.ready,
37
+ lastSeen: member.lastSeen,
38
+ blocked: providerState?.state === "blocked",
39
+ providerState: providerState ?? undefined,
40
+ };
41
+ });
42
+ return {
43
+ kind: "team.idle",
44
+ teamId,
45
+ coordinatorId,
46
+ thresholdMinutes,
47
+ thresholdMs: thresholdMinutes * 60_000,
48
+ idleForMs,
49
+ firstNoBusyAt,
50
+ roster,
51
+ blockedMembers: roster.filter((member) => member.blocked).map((member) => ({
52
+ id: member.id,
53
+ providerState: member.providerState,
54
+ })),
55
+ };
56
+ }
57
+
58
+ function notifyTeamIdle(teamId: string, coordinatorId: string, thresholdMinutes: number, idleForMs: number, firstNoBusyAt: number): string | null {
59
+ const roundedIdleMinutes = Math.max(thresholdMinutes, Math.floor(idleForMs / 60_000));
60
+ const payload = teamIdlePayload(teamId, coordinatorId, thresholdMinutes, idleForMs, firstNoBusyAt);
61
+ return notifyTarget(
62
+ "team.idle",
63
+ { teamId, agentId: coordinatorId },
64
+ coordinatorId,
65
+ "Team idle watchdog",
66
+ `Team \`${teamId}\` has had no busy members for ${roundedIdleMinutes} min. If the work is done, stand down. If not, check the team roster and restart unfinished work.`,
67
+ payload,
68
+ );
69
+ }
70
+
71
+ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string, unknown> {
72
+ const config = getTeamsConfig();
73
+ if (!config.enabled) {
74
+ const cleared = teamIdleTracker.size;
75
+ teamIdleTracker.clear();
76
+ return { enabled: false, clearedTrackedTeams: cleared };
77
+ }
78
+
79
+ const now = input.now ?? Date.now();
80
+ const cutoff = now - STALE_TTL_MS;
81
+ const thresholdMs = config.thresholdMinutes * 60_000;
82
+ const seen = new Set<string>();
83
+ const busyTeamIds: string[] = [];
84
+ const waitingTeamIds: string[] = [];
85
+ const pingedTeamIds: string[] = [];
86
+ const alreadyPingedTeamIds: string[] = [];
87
+ const missingCoordinatorTeamIds: string[] = [];
88
+
89
+ for (const team of listActiveTeams()) {
90
+ seen.add(team.id);
91
+ const busy = getTeamBusyState(team.id, cutoff);
92
+ if (busy.busyMemberIds.length > 0) {
93
+ teamIdleTracker.delete(team.id);
94
+ busyTeamIds.push(team.id);
95
+ continue;
96
+ }
97
+
98
+ const entry = teamIdleTracker.get(team.id) ?? { firstNoBusyAt: now, pinged: false };
99
+ teamIdleTracker.set(team.id, entry);
100
+ const idleForMs = now - entry.firstNoBusyAt;
101
+ if (idleForMs < thresholdMs) {
102
+ waitingTeamIds.push(team.id);
103
+ continue;
104
+ }
105
+
106
+ if (entry.pinged) {
107
+ alreadyPingedTeamIds.push(team.id);
108
+ continue;
109
+ }
110
+ if (!team.coordinatorId) {
111
+ missingCoordinatorTeamIds.push(team.id);
112
+ continue;
113
+ }
114
+
115
+ notifyTeamIdle(team.id, team.coordinatorId, config.thresholdMinutes, idleForMs, entry.firstNoBusyAt);
116
+ entry.pinged = true;
117
+ pingedTeamIds.push(team.id);
118
+ }
119
+
120
+ for (const key of teamIdleTracker.keys()) if (!seen.has(key)) teamIdleTracker.delete(key);
121
+
122
+ return {
123
+ enabled: true,
124
+ thresholdMinutes: config.thresholdMinutes,
125
+ trackedTeams: teamIdleTracker.size,
126
+ busyTeamIds,
127
+ waitingTeamIds,
128
+ pingedTeamIds,
129
+ alreadyPingedTeamIds,
130
+ missingCoordinatorTeamIds,
131
+ };
132
+ }
11
133
 
12
134
  export function reapZeroMemberTeams(): Record<string, unknown> {
13
135
  const now = Date.now();
@@ -10,6 +10,7 @@ export type NotificationType =
10
10
  | "agent.resume_budget_exhausted"
11
11
  | "agent.transient_land_hold"
12
12
  | "agent.rate_limit_resume"
13
+ | "team.idle"
13
14
  | "plan.bound_agent_exited"
14
15
  | "workspace.steward_task"
15
16
  | "workspace.conflict"
@@ -60,6 +61,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
60
61
  "agent.resume_budget_exhausted": null,
61
62
  "agent.transient_land_hold": null,
62
63
  "agent.rate_limit_resume": 15 * MINUTES,
64
+ "team.idle": null,
63
65
  "plan.bound_agent_exited": null,
64
66
  "workspace.steward_task": null,
65
67
  "workspace.conflict": null,
@@ -7,6 +7,7 @@ import { deleteAutomationById, getAutomationById, getAutomationRuns, getAutomati
7
7
  import { deleteCommandById, deleteProviderModelRoute, getProviderConfigRoute, getProviderConfigsRoute, getProvidersRoute, patchProviderModelRoute, postProviderConfigTestRoute, putProviderConfigRoute, putProviderModelRoute } from "./provider-config";
8
8
  import { getProviderQuotasRoute, postProviderQuotaLeaseAcquireRoute, postProviderQuotaLeaseReleaseRoute, postProviderQuotaRoute } from "./provider-quotas";
9
9
  import { getProviderQuotaConfigRoute, getProviderQuotaConfigsRoute, putProviderQuotaConfigRoute } from "./provider-quota-config";
10
+ import { getTeamsConfigRoute, putTeamsConfigRoute } from "./teams-config";
10
11
  import { deleteConfigKey, getConfigKey, getConfigKeyHistory, getConfigNamespace, putConfigKey } from "./config";
11
12
  import { deleteInboxDraftRoute, getChatHistoryImportsRoute, getInboxStateRoute, patchInboxThreadState, postChatHistoryImportRoute, putInboxDraft } from "./inbox";
12
13
  import { deleteMemoryRoute, getMemories, getMemoryBrokerInfo, getMemoryById, getMemoryStats, patchMemory, postMemory, postMemoryInject, postMemorySearch } from "./memory";
@@ -161,6 +162,8 @@ const routes: Route[] = [
161
162
  route("GET", "/api/merges/pauses/:id", getMergePauseById),
162
163
 
163
164
  route("POST", "/api/system/broadcast", postSystemBroadcast),
165
+ route("GET", "/api/teams/config", getTeamsConfigRoute),
166
+ route("PUT", "/api/teams/config", putTeamsConfigRoute),
164
167
  route("GET", "/api/teams", getTeamsRoute),
165
168
  route("GET", "/api/teams/outcomes", getTeamOutcomesRoute),
166
169
  route("GET", "/api/teams/me", getMyTeamRoute),
@@ -0,0 +1,15 @@
1
+ import { getTeamsConfig, setTeamsConfig } from "../teams-config-store";
2
+ import { cleanString } from "../validation";
3
+ import { error, json, parseBody, type Handler } from "./_shared";
4
+ import { isRecord } from "agent-relay-sdk";
5
+
6
+ export const getTeamsConfigRoute: Handler = () => {
7
+ return json(getTeamsConfig());
8
+ };
9
+
10
+ export const putTeamsConfigRoute: Handler = async (req) => {
11
+ const parsed = await parseBody<unknown>(req);
12
+ if (!parsed.ok) return error(parsed.error, parsed.status);
13
+ const updatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
14
+ return json(setTeamsConfig(parsed.body, { updatedBy }));
15
+ };
package/src/security.ts CHANGED
@@ -194,6 +194,7 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
194
194
  if (pathname.startsWith("/api/maintenance")) return "system:admin";
195
195
  if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
196
196
  if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
197
+ if (pathname === "/api/teams/config") return method === "GET" ? "teams:read" : "settings:write:teams";
197
198
  if (pathname.startsWith("/api/teams")) return method === "GET" ? "teams:read" : "teams:write";
198
199
  if (pathname.startsWith("/api/blackboard")) return method === "GET" ? "blackboard:read" : "blackboard:write";
199
200
  if (pathname.startsWith("/api/issue-associations")) return method === "GET" ? "issue:read" : "issue:write";
@@ -280,6 +281,7 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
280
281
  if (pathname === "/api/providers/quota-config" || pathname.match(/^\/api\/providers\/[^/]+\/quota-config$/)) {
281
282
  return method === "GET" ? "agent:read" : "settings:write:provider";
282
283
  }
284
+ if (pathname === "/api/teams/config") return method === "GET" ? "teams:read" : "settings:write:teams";
283
285
  if (pathname.startsWith("/api/messages") || pathname.startsWith("/api/inbox")) return method === "GET" ? "message:read" : "message:send";
284
286
  if (pathname.startsWith("/api/agent-profiles")) return method === "GET" ? "agent:read" : "agent:write";
285
287
  if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
@@ -304,7 +306,7 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
304
306
  return "system:admin";
305
307
  }
306
308
 
307
- const CONFIG_SETTINGS_NAMESPACES = new Set(["steward", "insights", "notifications", "workspace", "landing", "provider"]);
309
+ const CONFIG_SETTINGS_NAMESPACES = new Set(["steward", "insights", "notifications", "workspace", "landing", "provider", "teams"]);
308
310
 
309
311
  function requiredSettingsScopeFor(method: string, pathname: string): string | null | undefined {
310
312
  if (pathname === "/api/settings") return method === "GET" ? "settings:read" : null;
@@ -20,11 +20,14 @@ import {
20
20
  APPROVAL_MODES,
21
21
  errMessage,
22
22
  SPAWN_PROVIDERS,
23
+ TEAM_IDLE_THRESHOLD_MINUTES_MAX,
24
+ TEAM_IDLE_THRESHOLD_MINUTES_MIN,
23
25
  VALID_EFFORTS,
24
26
  type JSONSchema,
25
27
  type SettingDescriptor,
26
28
  type SettingScope,
27
29
  } from "agent-relay-sdk";
30
+ import { DEFAULT_TEAMS_CONFIG } from "agent-relay-sdk/teams-config";
28
31
 
29
32
  /** Fail-closed default scope — a descriptor that omits scope requires admin to
30
33
  * read and write (#385 owns the scope grammar; this layer only declares it). */
@@ -215,6 +218,20 @@ const WORKSPACE_SCHEMA: JSONSchema = {
215
218
  },
216
219
  };
217
220
 
221
+ const TEAMS_SCHEMA: JSONSchema = {
222
+ type: "object",
223
+ additionalProperties: false,
224
+ required: ["enabled", "thresholdMinutes"],
225
+ properties: {
226
+ enabled: { type: "boolean" },
227
+ thresholdMinutes: {
228
+ type: "integer",
229
+ minimum: TEAM_IDLE_THRESHOLD_MINUTES_MIN,
230
+ maximum: TEAM_IDLE_THRESHOLD_MINUTES_MAX,
231
+ },
232
+ },
233
+ };
234
+
218
235
  const LANDING_SCHEMA: JSONSchema = {
219
236
  type: "object",
220
237
  additionalProperties: false,
@@ -347,6 +364,16 @@ function seedBuiltInSettings(): void {
347
364
  display: { group: "Workspace", label: "Landing strategy", description: "Default land strategy for workspace branches.", order: 41 },
348
365
  });
349
366
 
367
+ registerSetting({
368
+ namespace: "teams",
369
+ key: "default",
370
+ schema: TEAMS_SCHEMA,
371
+ defaults: DEFAULT_TEAMS_CONFIG,
372
+ scope: { read: "settings:read:teams", write: "settings:write:teams" },
373
+ tier: "server-runtime",
374
+ display: { group: "Teams", label: "Team idle watchdog", description: "Coordinator nudge when a team has no busy members.", order: 45 },
375
+ });
376
+
350
377
  registerSetting({
351
378
  namespace: "provider",
352
379
  key: "chatCaptureMode",
@@ -0,0 +1,22 @@
1
+ import { getConfig, setConfig } from "./config-store";
2
+ import { emitConfigChanged } from "./sse";
3
+ import {
4
+ DEFAULT_TEAMS_CONFIG,
5
+ normalizeTeamsConfig,
6
+ type TeamsConfig,
7
+ } from "agent-relay-sdk";
8
+
9
+ const TEAMS_CONFIG_NAMESPACE = "teams";
10
+ const TEAMS_CONFIG_KEY = "default";
11
+
12
+ export function getTeamsConfig(): TeamsConfig {
13
+ const entry = getConfig<TeamsConfig>(TEAMS_CONFIG_NAMESPACE, TEAMS_CONFIG_KEY);
14
+ return entry ? normalizeTeamsConfig(entry.value) : { ...DEFAULT_TEAMS_CONFIG };
15
+ }
16
+
17
+ export function setTeamsConfig(value: unknown, opts: { updatedBy?: string } = {}): TeamsConfig {
18
+ const normalized = normalizeTeamsConfig(value);
19
+ const entry = setConfig(TEAMS_CONFIG_NAMESPACE, TEAMS_CONFIG_KEY, normalized, opts.updatedBy);
20
+ emitConfigChanged(entry.namespace, entry.key, entry.version);
21
+ return normalized;
22
+ }