nexus-agentd 0.2.5 → 0.3.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/README.md +359 -250
- package/dist/a2a/runtime.d.ts +7 -1
- package/dist/a2a/runtime.js +290 -17
- package/dist/acp/runtime.d.ts +5 -1
- package/dist/acp/runtime.js +88 -39
- package/dist/artifact-store.d.ts +162 -0
- package/dist/artifact-store.js +1081 -0
- package/dist/cli.js +13 -14
- package/dist/completion-contract.js +1 -1
- package/dist/config.js +45 -5
- package/dist/control-plane-validation.d.ts +54 -0
- package/dist/control-plane-validation.js +152 -0
- package/dist/control-plane.d.ts +9 -32
- package/dist/control-plane.js +52 -158
- package/dist/drivers/index.js +12 -0
- package/dist/drivers/pi.js +1 -1
- package/dist/drivers/stdio.d.ts +4 -1
- package/dist/drivers/stdio.js +135 -19
- package/dist/events.d.ts +9 -0
- package/dist/events.js +17 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +33 -6
- package/dist/process-tree.js +15 -6
- package/dist/run-store.d.ts +57 -7
- package/dist/run-store.js +188 -23
- package/dist/server/auth-policy.d.ts +27 -0
- package/dist/server/auth-policy.js +143 -0
- package/dist/server/connection.d.ts +8 -0
- package/dist/server/connection.js +67 -0
- package/dist/server/diagnostics.d.ts +40 -0
- package/dist/server/diagnostics.js +130 -0
- package/dist/server/metrics.d.ts +51 -0
- package/dist/server/metrics.js +56 -0
- package/dist/server/quota.d.ts +59 -0
- package/dist/server/quota.js +176 -0
- package/dist/server/validation.d.ts +29 -0
- package/dist/server/validation.js +221 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +682 -418
- package/dist/session/admin-runs.d.ts +73 -0
- package/dist/session/admin-runs.js +297 -0
- package/dist/session/admission.d.ts +32 -0
- package/dist/session/admission.js +62 -0
- package/dist/session/readiness.d.ts +33 -0
- package/dist/session/readiness.js +174 -0
- package/dist/session-contract.d.ts +1 -0
- package/dist/session.d.ts +64 -7
- package/dist/session.js +321 -159
- package/dist/sse-writer.d.ts +7 -0
- package/dist/sse-writer.js +60 -0
- package/dist/types.d.ts +44 -2
- package/dist/types.js +1 -0
- package/dist/webui/app/sources.js +18 -10
- package/dist/webui/index.js +11 -11
- package/dist/webui/markup.js +69 -66
- package/dist/webui/styles.js +791 -616
- package/dist/workspace.d.ts +30 -0
- package/dist/workspace.js +67 -2
- package/examples/client.mjs +102 -0
- package/nexus-agentd.example.json +21 -2
- package/package.json +6 -3
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { AgentdAgentConfig, AgentdRunControls, AgentdPendingResponse, AgentdRunDetail, AgentdSessionState, AgentdSessionView } from '../types.js';
|
|
2
|
+
export type { AgentdRunControls };
|
|
3
|
+
export type AdminRunControls = AgentdRunControls;
|
|
4
|
+
export interface AdminRunOperationResult {
|
|
5
|
+
run: AgentdRunDetail;
|
|
6
|
+
controls: AdminRunControls;
|
|
7
|
+
state: AgentdSessionState;
|
|
8
|
+
runId: string;
|
|
9
|
+
sessionId: string;
|
|
10
|
+
retryOfRunId?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Quota state reserved for a retry that is about to create a new session.
|
|
14
|
+
* The reservation is bound as soon as the new session exists so lifecycle
|
|
15
|
+
* events can release each resource independently.
|
|
16
|
+
*/
|
|
17
|
+
export interface AdminRetryReservation {
|
|
18
|
+
bind(sessionId: string): void;
|
|
19
|
+
release(): void;
|
|
20
|
+
}
|
|
21
|
+
export type AdminRetryReservationFactory = (ownerKeyId: string) => AdminRetryReservation;
|
|
22
|
+
export interface AdminRunSession {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly state: AgentdSessionState;
|
|
25
|
+
readonly ownerKeyId: string;
|
|
26
|
+
snapshot(): AgentdSessionView;
|
|
27
|
+
runtimeAvailable(): boolean;
|
|
28
|
+
cancel(): Promise<void>;
|
|
29
|
+
resolvePending(response: AgentdPendingResponse, attachmentIds?: string[]): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export interface AdminRunOptions {
|
|
32
|
+
getRun(runId: string): AgentdRunDetail;
|
|
33
|
+
getSession(sessionId: string): AdminRunSession | undefined;
|
|
34
|
+
getAgent(agentId: string): AgentdAgentConfig | undefined;
|
|
35
|
+
getOwnerKeyId(runId: string): string | undefined;
|
|
36
|
+
withSessionLock<T>(sessionId: string, operation: () => Promise<T>): Promise<T>;
|
|
37
|
+
startRetry(run: AgentdRunDetail, ownerKeyId: string, reservation?: AdminRetryReservation): Promise<{
|
|
38
|
+
runId: string;
|
|
39
|
+
sessionId: string;
|
|
40
|
+
}>;
|
|
41
|
+
markRetryOf?(newRunId: string, oldRunId: string): unknown;
|
|
42
|
+
requestError(status: number, message: string): Error;
|
|
43
|
+
/** Optional bounds/clock overrides for embedding and deterministic tests. */
|
|
44
|
+
maxRetryRecords?: number;
|
|
45
|
+
retryRecordTtlMs?: number;
|
|
46
|
+
now?: () => number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Administrator operations are keyed by persisted run IDs rather than API
|
|
50
|
+
* keys. The session identity check is repeated inside the per-session lock
|
|
51
|
+
* immediately before every action; an old run can therefore never cancel or
|
|
52
|
+
* answer a newer turn on the same session.
|
|
53
|
+
*/
|
|
54
|
+
export declare class AdminRunManager {
|
|
55
|
+
private readonly options;
|
|
56
|
+
private retryRecords;
|
|
57
|
+
private retryRelations;
|
|
58
|
+
private readonly maxRetryRecords;
|
|
59
|
+
private readonly retryRecordTtlMs;
|
|
60
|
+
private readonly now;
|
|
61
|
+
constructor(options: AdminRunOptions);
|
|
62
|
+
getRunControls(runId: string): AdminRunControls;
|
|
63
|
+
cancelRun(runId: string): Promise<AdminRunOperationResult>;
|
|
64
|
+
respondRun(runId: string, response: AgentdPendingResponse, attachmentIds?: string[]): Promise<AdminRunOperationResult>;
|
|
65
|
+
retryRun(runId: string, reserveRetry?: AdminRetryReservationFactory): Promise<AdminRunOperationResult>;
|
|
66
|
+
retryOfRunId(runId: string): string | undefined;
|
|
67
|
+
private currentRetryRecord;
|
|
68
|
+
private pruneRetryState;
|
|
69
|
+
private dropRetryRecord;
|
|
70
|
+
private dropRetryRelation;
|
|
71
|
+
private assertCurrent;
|
|
72
|
+
private resultFor;
|
|
73
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
const DEFAULT_MAX_RETRY_RECORDS = 512;
|
|
2
|
+
const DEFAULT_RETRY_RECORD_TTL_MS = 24 * 60 * 60 * 1000;
|
|
3
|
+
/**
|
|
4
|
+
* Administrator operations are keyed by persisted run IDs rather than API
|
|
5
|
+
* keys. The session identity check is repeated inside the per-session lock
|
|
6
|
+
* immediately before every action; an old run can therefore never cancel or
|
|
7
|
+
* answer a newer turn on the same session.
|
|
8
|
+
*/
|
|
9
|
+
export class AdminRunManager {
|
|
10
|
+
options;
|
|
11
|
+
retryRecords = new Map();
|
|
12
|
+
retryRelations = new Map();
|
|
13
|
+
maxRetryRecords;
|
|
14
|
+
retryRecordTtlMs;
|
|
15
|
+
now;
|
|
16
|
+
constructor(options) {
|
|
17
|
+
this.options = options;
|
|
18
|
+
this.maxRetryRecords = positiveInteger(options.maxRetryRecords, DEFAULT_MAX_RETRY_RECORDS);
|
|
19
|
+
this.retryRecordTtlMs = positiveInteger(options.retryRecordTtlMs, DEFAULT_RETRY_RECORD_TTL_MS);
|
|
20
|
+
this.now = options.now || Date.now;
|
|
21
|
+
}
|
|
22
|
+
getRunControls(runId) {
|
|
23
|
+
this.pruneRetryState();
|
|
24
|
+
const run = this.options.getRun(runId);
|
|
25
|
+
const session = this.options.getSession(run.sessionId);
|
|
26
|
+
const snapshot = session?.snapshot();
|
|
27
|
+
const current = Boolean(snapshot && snapshot.runId === run.id);
|
|
28
|
+
const effectiveState = current ? snapshot.state : run.state;
|
|
29
|
+
const active = isActive(effectiveState);
|
|
30
|
+
const pendingRequest = current && snapshot?.pendingRequest
|
|
31
|
+
? structuredClone(snapshot.pendingRequest)
|
|
32
|
+
: undefined;
|
|
33
|
+
let unavailableReason;
|
|
34
|
+
// A terminal historical run does not need its in-memory session in
|
|
35
|
+
// order to be retried. Session availability only explains why the
|
|
36
|
+
// current active turn cannot be controlled. A session may already be
|
|
37
|
+
// processing a newer turn while an older terminal run is retried in
|
|
38
|
+
// its own session.
|
|
39
|
+
if (isActive(run.state)) {
|
|
40
|
+
if (!session) {
|
|
41
|
+
unavailableReason = 'The run session is no longer available';
|
|
42
|
+
}
|
|
43
|
+
else if (!current) {
|
|
44
|
+
unavailableReason = 'The run is no longer the current session turn';
|
|
45
|
+
}
|
|
46
|
+
else if (!session.runtimeAvailable()) {
|
|
47
|
+
unavailableReason = 'Agent runtime is unavailable';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
let canRetry = isTerminal(run.state);
|
|
51
|
+
if (canRetry && run.taskTruncated) {
|
|
52
|
+
canRetry = false;
|
|
53
|
+
unavailableReason =
|
|
54
|
+
'This run cannot be retried because its task record was truncated';
|
|
55
|
+
}
|
|
56
|
+
else if (canRetry && (run.inputAttachmentCount || 0) > 0) {
|
|
57
|
+
canRetry = false;
|
|
58
|
+
unavailableReason =
|
|
59
|
+
'This run cannot be retried because it used input attachments';
|
|
60
|
+
}
|
|
61
|
+
else if (canRetry) {
|
|
62
|
+
const agent = this.options.getAgent(run.agentId);
|
|
63
|
+
if (!agent || agent.enabled === false) {
|
|
64
|
+
canRetry = false;
|
|
65
|
+
unavailableReason = 'The configured agent is unavailable';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const retryRecord = this.currentRetryRecord(run.id);
|
|
69
|
+
if (canRetry && retryRecord?.status === 'pending') {
|
|
70
|
+
canRetry = false;
|
|
71
|
+
unavailableReason = 'Retry is already in progress for this run';
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
canCancel: current && active && Boolean(session?.runtimeAvailable()),
|
|
75
|
+
canRetry,
|
|
76
|
+
...(pendingRequest ? { pendingRequest } : {}),
|
|
77
|
+
...(unavailableReason ? { unavailableReason } : {})
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
async cancelRun(runId) {
|
|
81
|
+
const run = this.options.getRun(runId);
|
|
82
|
+
return this.options.withSessionLock(run.sessionId, async () => {
|
|
83
|
+
const current = this.assertCurrent(runId);
|
|
84
|
+
if (!isActive(current.state)) {
|
|
85
|
+
throw this.options.requestError(409, 'The run is no longer active');
|
|
86
|
+
}
|
|
87
|
+
if (!current.runtimeAvailable()) {
|
|
88
|
+
throw this.options.requestError(409, 'Agent runtime is unavailable');
|
|
89
|
+
}
|
|
90
|
+
await current.cancel();
|
|
91
|
+
return this.resultFor(runId);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
async respondRun(runId, response, attachmentIds = []) {
|
|
95
|
+
const run = this.options.getRun(runId);
|
|
96
|
+
return this.options.withSessionLock(run.sessionId, async () => {
|
|
97
|
+
const current = this.assertCurrent(runId);
|
|
98
|
+
if (!current.runtimeAvailable()) {
|
|
99
|
+
throw this.options.requestError(409, 'Agent runtime is unavailable');
|
|
100
|
+
}
|
|
101
|
+
if (!current.snapshot().pendingRequest) {
|
|
102
|
+
throw this.options.requestError(409, 'Pending request is no longer available');
|
|
103
|
+
}
|
|
104
|
+
await current.resolvePending(response, attachmentIds);
|
|
105
|
+
return this.resultFor(runId);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
async retryRun(runId, reserveRetry) {
|
|
109
|
+
this.pruneRetryState();
|
|
110
|
+
const existing = this.currentRetryRecord(runId);
|
|
111
|
+
if (existing) {
|
|
112
|
+
if (existing.status === 'pending') {
|
|
113
|
+
throw this.options.requestError(409, 'Retry is already in progress for this run');
|
|
114
|
+
}
|
|
115
|
+
return this.resultFor(existing.newRunId, existing.retryOfRunId, existing.sessionId);
|
|
116
|
+
}
|
|
117
|
+
const run = this.options.getRun(runId);
|
|
118
|
+
if (!isTerminal(run.state)) {
|
|
119
|
+
throw this.options.requestError(409, 'Only terminal runs can be retried');
|
|
120
|
+
}
|
|
121
|
+
if (run.taskTruncated) {
|
|
122
|
+
throw this.options.requestError(409, 'This run cannot be retried because its task record was truncated');
|
|
123
|
+
}
|
|
124
|
+
if ((run.inputAttachmentCount || 0) > 0) {
|
|
125
|
+
throw this.options.requestError(409, 'This run cannot be retried because it used input attachments');
|
|
126
|
+
}
|
|
127
|
+
// Set this synchronously before the first await. A second request in
|
|
128
|
+
// the same event-loop turn therefore receives 409 instead of creating
|
|
129
|
+
// another session.
|
|
130
|
+
const timestamp = this.now();
|
|
131
|
+
this.retryRecords.set(runId, {
|
|
132
|
+
retryOfRunId: runId,
|
|
133
|
+
status: 'pending',
|
|
134
|
+
createdAt: timestamp,
|
|
135
|
+
updatedAt: timestamp
|
|
136
|
+
});
|
|
137
|
+
let started;
|
|
138
|
+
let reservation;
|
|
139
|
+
try {
|
|
140
|
+
const ownerKeyId = this.options.getOwnerKeyId(runId);
|
|
141
|
+
if (!ownerKeyId) {
|
|
142
|
+
throw this.options.requestError(409, 'The original run owner is unavailable');
|
|
143
|
+
}
|
|
144
|
+
// The record above is the synchronous deduplication gate. Only
|
|
145
|
+
// after it succeeds may the caller reserve resources for a new
|
|
146
|
+
// retry; a completed duplicate therefore never consumes quota.
|
|
147
|
+
reservation = reserveRetry?.(ownerKeyId);
|
|
148
|
+
started = await this.options.startRetry(run, ownerKeyId, reservation);
|
|
149
|
+
reservation?.bind(started.sessionId);
|
|
150
|
+
this.retryRelations.set(started.runId, runId);
|
|
151
|
+
try {
|
|
152
|
+
void Promise.resolve(this.options.markRetryOf?.(started.runId, runId)).catch(() => undefined);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// The in-memory relation still makes this attempt dedupable.
|
|
156
|
+
// Persistence failures must not turn a successfully started
|
|
157
|
+
// task into an apparent retry failure.
|
|
158
|
+
}
|
|
159
|
+
this.retryRecords.set(runId, {
|
|
160
|
+
retryOfRunId: runId,
|
|
161
|
+
status: 'succeeded',
|
|
162
|
+
newRunId: started.runId,
|
|
163
|
+
sessionId: started.sessionId,
|
|
164
|
+
createdAt: timestamp,
|
|
165
|
+
updatedAt: this.now()
|
|
166
|
+
});
|
|
167
|
+
this.pruneRetryState();
|
|
168
|
+
return this.resultFor(started.runId, runId, started.sessionId);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
reservation?.release();
|
|
172
|
+
this.dropRetryRecord(runId);
|
|
173
|
+
if (started)
|
|
174
|
+
this.dropRetryRelation(started.runId, runId);
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
retryOfRunId(runId) {
|
|
179
|
+
this.pruneRetryState();
|
|
180
|
+
const sourceRunId = this.retryRelations.get(runId);
|
|
181
|
+
if (!sourceRunId)
|
|
182
|
+
return undefined;
|
|
183
|
+
try {
|
|
184
|
+
this.options.getRun(runId);
|
|
185
|
+
return sourceRunId;
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (!isMissingRunError(error))
|
|
189
|
+
throw error;
|
|
190
|
+
const record = this.retryRecords.get(sourceRunId);
|
|
191
|
+
if (record?.newRunId === runId) {
|
|
192
|
+
this.dropRetryRecord(sourceRunId, record);
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
this.dropRetryRelation(runId, sourceRunId);
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
currentRetryRecord(runId) {
|
|
201
|
+
const record = this.retryRecords.get(runId);
|
|
202
|
+
if (!record || record.status === 'pending')
|
|
203
|
+
return record;
|
|
204
|
+
if (!record.newRunId) {
|
|
205
|
+
this.dropRetryRecord(runId, record);
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
this.options.getRun(record.newRunId);
|
|
210
|
+
return record;
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
if (!isMissingRunError(error))
|
|
214
|
+
throw error;
|
|
215
|
+
this.dropRetryRecord(runId, record);
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
pruneRetryState() {
|
|
220
|
+
const now = this.now();
|
|
221
|
+
for (const [runId, record] of this.retryRecords) {
|
|
222
|
+
if (record.status === 'succeeded' &&
|
|
223
|
+
now - record.updatedAt >= this.retryRecordTtlMs) {
|
|
224
|
+
this.dropRetryRecord(runId, record);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (this.retryRecords.size <= this.maxRetryRecords)
|
|
228
|
+
return;
|
|
229
|
+
const succeeded = Array.from(this.retryRecords.entries())
|
|
230
|
+
.filter(([, record]) => record.status === 'succeeded')
|
|
231
|
+
.sort((left, right) => left[1].updatedAt - right[1].updatedAt);
|
|
232
|
+
for (const [runId, record] of succeeded) {
|
|
233
|
+
if (this.retryRecords.size <= this.maxRetryRecords)
|
|
234
|
+
break;
|
|
235
|
+
this.dropRetryRecord(runId, record);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
dropRetryRecord(runId, record) {
|
|
239
|
+
const current = this.retryRecords.get(runId);
|
|
240
|
+
if (!current || (record && current !== record))
|
|
241
|
+
return;
|
|
242
|
+
this.retryRecords.delete(runId);
|
|
243
|
+
if (current.newRunId) {
|
|
244
|
+
this.dropRetryRelation(current.newRunId, runId);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
dropRetryRelation(newRunId, sourceRunId) {
|
|
248
|
+
if (this.retryRelations.get(newRunId) === sourceRunId) {
|
|
249
|
+
this.retryRelations.delete(newRunId);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
assertCurrent(runId) {
|
|
253
|
+
const run = this.options.getRun(runId);
|
|
254
|
+
const session = this.options.getSession(run.sessionId);
|
|
255
|
+
if (!session) {
|
|
256
|
+
throw this.options.requestError(409, 'The run session is no longer available');
|
|
257
|
+
}
|
|
258
|
+
const snapshot = session.snapshot();
|
|
259
|
+
if (snapshot.runId !== runId) {
|
|
260
|
+
throw this.options.requestError(409, 'The run is no longer the current session turn');
|
|
261
|
+
}
|
|
262
|
+
return session;
|
|
263
|
+
}
|
|
264
|
+
resultFor(runId, retryOfRunId, sessionId) {
|
|
265
|
+
const run = this.options.getRun(runId);
|
|
266
|
+
const controls = this.getRunControls(runId);
|
|
267
|
+
return {
|
|
268
|
+
run: structuredClone(run),
|
|
269
|
+
controls,
|
|
270
|
+
state: run.state,
|
|
271
|
+
runId: run.id,
|
|
272
|
+
sessionId: sessionId || run.sessionId,
|
|
273
|
+
...(retryOfRunId ? { retryOfRunId } : {})
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function positiveInteger(value, fallback) {
|
|
278
|
+
return Number.isFinite(value) && Number(value) > 0
|
|
279
|
+
? Math.floor(Number(value))
|
|
280
|
+
: fallback;
|
|
281
|
+
}
|
|
282
|
+
function isMissingRunError(error) {
|
|
283
|
+
if (isRecord(error) && error.status === 404)
|
|
284
|
+
return true;
|
|
285
|
+
return error instanceof Error && /run not found/i.test(error.message);
|
|
286
|
+
}
|
|
287
|
+
function isRecord(value) {
|
|
288
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
289
|
+
}
|
|
290
|
+
function isActive(state) {
|
|
291
|
+
return (state === 'running' ||
|
|
292
|
+
state === 'input_required' ||
|
|
293
|
+
state === 'permission_required');
|
|
294
|
+
}
|
|
295
|
+
function isTerminal(state) {
|
|
296
|
+
return state === 'completed' || state === 'failed' || state === 'canceled';
|
|
297
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { AgentdSessionState } from '../types.js';
|
|
2
|
+
export interface AdmissionSession {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly state: AgentdSessionState;
|
|
5
|
+
readonly updatedAt: number;
|
|
6
|
+
dispose(): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export interface SessionAdmissionOptions {
|
|
9
|
+
maxSessions(): number;
|
|
10
|
+
sessionCount(): number;
|
|
11
|
+
sessions(): Iterable<AdmissionSession>;
|
|
12
|
+
isCurrent(session: AdmissionSession): boolean;
|
|
13
|
+
withSessionLock<T>(id: string, operation: () => Promise<T>): Promise<T>;
|
|
14
|
+
removeSession(id: string): void;
|
|
15
|
+
requestError(status: number, message: string): Error;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Serialises admission decisions and owns the pending-create reservation.
|
|
19
|
+
* A reservation is held until the session has been inserted, so concurrent
|
|
20
|
+
* creates cannot temporarily exceed maxSessions while an ACP process starts.
|
|
21
|
+
*/
|
|
22
|
+
export declare class SessionAdmission {
|
|
23
|
+
private readonly options;
|
|
24
|
+
private pendingCreates;
|
|
25
|
+
private closing;
|
|
26
|
+
constructor(options: SessionAdmissionOptions);
|
|
27
|
+
markClosing(): void;
|
|
28
|
+
isClosing(): boolean;
|
|
29
|
+
pendingCount(): number;
|
|
30
|
+
reserve(): Promise<() => void>;
|
|
31
|
+
ensureCapacity(): Promise<void>;
|
|
32
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialises admission decisions and owns the pending-create reservation.
|
|
3
|
+
* A reservation is held until the session has been inserted, so concurrent
|
|
4
|
+
* creates cannot temporarily exceed maxSessions while an ACP process starts.
|
|
5
|
+
*/
|
|
6
|
+
export class SessionAdmission {
|
|
7
|
+
options;
|
|
8
|
+
pendingCreates = 0;
|
|
9
|
+
closing = false;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.options = options;
|
|
12
|
+
}
|
|
13
|
+
markClosing() {
|
|
14
|
+
this.closing = true;
|
|
15
|
+
}
|
|
16
|
+
isClosing() {
|
|
17
|
+
return this.closing;
|
|
18
|
+
}
|
|
19
|
+
pendingCount() {
|
|
20
|
+
return this.pendingCreates;
|
|
21
|
+
}
|
|
22
|
+
async reserve() {
|
|
23
|
+
if (this.closing) {
|
|
24
|
+
throw this.options.requestError(503, 'Gateway is shutting down');
|
|
25
|
+
}
|
|
26
|
+
await this.ensureCapacity();
|
|
27
|
+
this.pendingCreates++;
|
|
28
|
+
let reserved = true;
|
|
29
|
+
return () => {
|
|
30
|
+
if (!reserved)
|
|
31
|
+
return;
|
|
32
|
+
reserved = false;
|
|
33
|
+
this.pendingCreates--;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
async ensureCapacity() {
|
|
37
|
+
const limit = this.options.maxSessions();
|
|
38
|
+
if (this.options.sessionCount() + this.pendingCreates < limit)
|
|
39
|
+
return;
|
|
40
|
+
const terminal = Array.from(this.options.sessions())
|
|
41
|
+
.filter((session) => isTerminal(session.state))
|
|
42
|
+
.sort((left, right) => left.updatedAt - right.updatedAt);
|
|
43
|
+
while (this.options.sessionCount() + this.pendingCreates >= limit &&
|
|
44
|
+
terminal.length) {
|
|
45
|
+
const session = terminal.shift();
|
|
46
|
+
await this.options.withSessionLock(session.id, async () => {
|
|
47
|
+
if (!this.options.isCurrent(session) ||
|
|
48
|
+
!isTerminal(session.state)) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
await session.dispose();
|
|
52
|
+
this.options.removeSession(session.id);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (this.options.sessionCount() + this.pendingCreates >= limit) {
|
|
56
|
+
throw this.options.requestError(429, 'Session capacity has been reached');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function isTerminal(state) {
|
|
61
|
+
return state === 'completed' || state === 'failed' || state === 'canceled';
|
|
62
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { AgentDriver } from '../drivers/index.js';
|
|
2
|
+
import type { AgentdAgentView, AgentdConfig } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Dependencies needed by the readiness cache. The error factory lives in
|
|
5
|
+
* session.ts so callers still receive the gateway's normal request error type
|
|
6
|
+
* without making this helper depend on SessionManager.
|
|
7
|
+
*/
|
|
8
|
+
export interface ReadinessOptions {
|
|
9
|
+
requestError(status: number, message: string): Error;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Per-agent readiness probes are deliberately kept outside SessionManager.
|
|
13
|
+
* Besides making the manager easier to reason about, this keeps an in-flight
|
|
14
|
+
* probe shared by all callers and bounds the number of child processes or
|
|
15
|
+
* remote card requests started by a dashboard refresh.
|
|
16
|
+
*/
|
|
17
|
+
export declare class SessionReadiness {
|
|
18
|
+
private readonly options;
|
|
19
|
+
private readonly maxConcurrent;
|
|
20
|
+
private readonly cacheMs;
|
|
21
|
+
private config;
|
|
22
|
+
private drivers;
|
|
23
|
+
private cache;
|
|
24
|
+
private inFlight;
|
|
25
|
+
private generation;
|
|
26
|
+
private activeProbes;
|
|
27
|
+
private waiters;
|
|
28
|
+
constructor(config: AgentdConfig, drivers: Map<string, AgentDriver>, options: ReadinessOptions, maxConcurrent?: number, cacheMs?: number);
|
|
29
|
+
reconfigure(config: AgentdConfig, drivers: Map<string, AgentDriver>): void;
|
|
30
|
+
listAgents(agentIds?: Set<string>, force?: boolean): Promise<AgentdAgentView[]>;
|
|
31
|
+
private withProbeSlot;
|
|
32
|
+
private defaultWorkspace;
|
|
33
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { probeA2AAgent } from '../a2a/runtime.js';
|
|
2
|
+
/**
|
|
3
|
+
* Per-agent readiness probes are deliberately kept outside SessionManager.
|
|
4
|
+
* Besides making the manager easier to reason about, this keeps an in-flight
|
|
5
|
+
* probe shared by all callers and bounds the number of child processes or
|
|
6
|
+
* remote card requests started by a dashboard refresh.
|
|
7
|
+
*/
|
|
8
|
+
export class SessionReadiness {
|
|
9
|
+
options;
|
|
10
|
+
maxConcurrent;
|
|
11
|
+
cacheMs;
|
|
12
|
+
config;
|
|
13
|
+
drivers;
|
|
14
|
+
cache = new Map();
|
|
15
|
+
inFlight = new Map();
|
|
16
|
+
generation = 0;
|
|
17
|
+
activeProbes = 0;
|
|
18
|
+
waiters = [];
|
|
19
|
+
constructor(config, drivers, options, maxConcurrent = 4, cacheMs = 20_000) {
|
|
20
|
+
this.options = options;
|
|
21
|
+
this.maxConcurrent = maxConcurrent;
|
|
22
|
+
this.cacheMs = cacheMs;
|
|
23
|
+
this.config = config;
|
|
24
|
+
this.drivers = drivers;
|
|
25
|
+
}
|
|
26
|
+
reconfigure(config, drivers) {
|
|
27
|
+
const probesChanged = JSON.stringify([this.config.agents, this.config.workspaceRoots]) !==
|
|
28
|
+
JSON.stringify([config.agents, config.workspaceRoots]);
|
|
29
|
+
this.config = config;
|
|
30
|
+
this.drivers = drivers;
|
|
31
|
+
if (probesChanged) {
|
|
32
|
+
this.generation++;
|
|
33
|
+
this.cache.clear();
|
|
34
|
+
// Existing probes are allowed to settle. The generation check
|
|
35
|
+
// below prevents their results from becoming the new cache.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async listAgents(agentIds, force = false) {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
const catalogGeneration = this.generation;
|
|
41
|
+
const agents = await Promise.all(Object.entries(this.config.agents)
|
|
42
|
+
.filter(([id]) => !agentIds || agentIds.has(id))
|
|
43
|
+
.map(async ([id, config]) => {
|
|
44
|
+
const cached = this.cache.get(id);
|
|
45
|
+
if (cached &&
|
|
46
|
+
now - cached.checkedAt < (force ? 5_000 : this.cacheMs)) {
|
|
47
|
+
return structuredClone(cached.agent);
|
|
48
|
+
}
|
|
49
|
+
const existing = this.inFlight.get(id);
|
|
50
|
+
if (existing) {
|
|
51
|
+
const value = await existing.promise;
|
|
52
|
+
if (existing.generation !== this.generation) {
|
|
53
|
+
throw this.options.requestError(409, 'Agent configuration changed; retry readiness');
|
|
54
|
+
}
|
|
55
|
+
return structuredClone(value);
|
|
56
|
+
}
|
|
57
|
+
const generation = this.generation;
|
|
58
|
+
const probeAgent = async () => {
|
|
59
|
+
if (config.protocol === 'a2a') {
|
|
60
|
+
return probeA2AAgent(id, config);
|
|
61
|
+
}
|
|
62
|
+
if (config.enabled === false) {
|
|
63
|
+
return {
|
|
64
|
+
id,
|
|
65
|
+
name: config.name || id,
|
|
66
|
+
description: config.description,
|
|
67
|
+
protocol: 'acp',
|
|
68
|
+
driver: config.driver,
|
|
69
|
+
ready: false,
|
|
70
|
+
enabled: false,
|
|
71
|
+
workspace: this.defaultWorkspace(id),
|
|
72
|
+
error: 'Agent is disabled',
|
|
73
|
+
checkedAt: Date.now()
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const driver = this.drivers.get(id);
|
|
77
|
+
if (!driver) {
|
|
78
|
+
return {
|
|
79
|
+
id,
|
|
80
|
+
name: config.name || id,
|
|
81
|
+
description: config.description,
|
|
82
|
+
protocol: 'acp',
|
|
83
|
+
driver: config.driver,
|
|
84
|
+
ready: false,
|
|
85
|
+
enabled: true,
|
|
86
|
+
workspace: this.defaultWorkspace(id),
|
|
87
|
+
error: 'ACP driver is unavailable',
|
|
88
|
+
checkedAt: Date.now()
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const startedAt = Date.now();
|
|
92
|
+
try {
|
|
93
|
+
const probe = await driver.probe();
|
|
94
|
+
return {
|
|
95
|
+
...probe,
|
|
96
|
+
protocol: 'acp',
|
|
97
|
+
driver: config.driver,
|
|
98
|
+
enabled: true,
|
|
99
|
+
workspace: this.defaultWorkspace(id),
|
|
100
|
+
checkedAt: Date.now(),
|
|
101
|
+
responseMs: Date.now() - startedAt
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
return {
|
|
106
|
+
id,
|
|
107
|
+
name: config.name || id,
|
|
108
|
+
description: config.description,
|
|
109
|
+
protocol: 'acp',
|
|
110
|
+
driver: config.driver,
|
|
111
|
+
ready: false,
|
|
112
|
+
enabled: true,
|
|
113
|
+
workspace: this.defaultWorkspace(id),
|
|
114
|
+
error: errorMessage(error),
|
|
115
|
+
checkedAt: Date.now(),
|
|
116
|
+
responseMs: Date.now() - startedAt
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const pending = this.withProbeSlot(async () => {
|
|
121
|
+
if (generation !== this.generation) {
|
|
122
|
+
throw this.options.requestError(409, 'Agent configuration changed; retry readiness');
|
|
123
|
+
}
|
|
124
|
+
return probeAgent();
|
|
125
|
+
});
|
|
126
|
+
this.inFlight.set(id, { generation, promise: pending });
|
|
127
|
+
try {
|
|
128
|
+
const agent = await pending;
|
|
129
|
+
if (generation === this.generation) {
|
|
130
|
+
this.cache.set(id, {
|
|
131
|
+
checkedAt: Date.now(),
|
|
132
|
+
agent: structuredClone(agent)
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return agent;
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
if (this.inFlight.get(id)?.promise === pending) {
|
|
139
|
+
this.inFlight.delete(id);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}));
|
|
143
|
+
if (catalogGeneration !== this.generation) {
|
|
144
|
+
throw this.options.requestError(409, 'Agent configuration changed; retry readiness');
|
|
145
|
+
}
|
|
146
|
+
agents.sort((left, right) => left.name.localeCompare(right.name));
|
|
147
|
+
return agents.map((agent) => structuredClone(agent));
|
|
148
|
+
}
|
|
149
|
+
async withProbeSlot(operation) {
|
|
150
|
+
if (this.activeProbes < this.maxConcurrent)
|
|
151
|
+
this.activeProbes++;
|
|
152
|
+
else
|
|
153
|
+
await new Promise((resolve) => this.waiters.push(resolve));
|
|
154
|
+
try {
|
|
155
|
+
return await operation();
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
const next = this.waiters.shift();
|
|
159
|
+
if (next)
|
|
160
|
+
next();
|
|
161
|
+
else
|
|
162
|
+
this.activeProbes--;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
defaultWorkspace(agentId) {
|
|
166
|
+
const config = this.config.agents[agentId];
|
|
167
|
+
if (config?.protocol === 'a2a')
|
|
168
|
+
return '';
|
|
169
|
+
return config?.workspace || this.config.workspaceRoots[0] || '';
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function errorMessage(error) {
|
|
173
|
+
return error instanceof Error ? error.message : String(error);
|
|
174
|
+
}
|
|
@@ -18,6 +18,7 @@ export interface AcpSessionSink extends AgentSessionSink {
|
|
|
18
18
|
setAcpSessionId(id: string): void;
|
|
19
19
|
}
|
|
20
20
|
export interface AgentSessionRuntime {
|
|
21
|
+
isAvailable?(): boolean;
|
|
21
22
|
start(workspace?: string): Promise<void>;
|
|
22
23
|
prompt(message: string, attachments?: AgentdInputAttachment[]): Promise<void>;
|
|
23
24
|
respondPending(response: AgentdPendingResponse | string, attachments?: AgentdInputAttachment[]): Promise<void>;
|