agent-relay-runner 0.127.6 → 0.127.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.127.6",
3
+ "version": "0.127.7",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.127.6",
4
+ "version": "0.127.7",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
package/src/adapter.ts CHANGED
@@ -124,6 +124,11 @@ export interface ProviderPermissionPromptHandler {
124
124
  buildView(id: string, body: Record<string, unknown>, reasoningSettled: boolean): InteractivePrompt;
125
125
  buildHookResponse(decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): Record<string, unknown>;
126
126
  buildResolvedPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>, occurredAt: number): ProviderPermissionResolvedPrompt;
127
+ // #1173: for prompt kinds whose PreToolUse hook is answered immediately (deny-then-hold —
128
+ // see control-server.ts's AskUserQuestion branch) rather than held open for the decision,
129
+ // this renders the eventual "answer" decision as the normal user-prompt text to inject back
130
+ // into the session. Absent for prompt kinds that still ride the hook response directly.
131
+ buildAnswerInjectionPrompt?(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): string;
127
132
  }
128
133
 
129
134
  export interface ProviderConfig {
@@ -12,6 +12,7 @@ export const claudePermissionPromptHandler: ProviderPermissionPromptHandler = {
12
12
  buildView: claudePermissionApprovalView,
13
13
  buildHookResponse: claudePermissionHookResponse,
14
14
  buildResolvedPrompt: claudeResolvedPermissionPrompt,
15
+ buildAnswerInjectionPrompt: claudeAnswerInjectionPrompt,
15
16
  };
16
17
 
17
18
  function claudePermissionApprovalView(id: string, body: Record<string, unknown>, reasoningSettled: boolean): InteractivePrompt {
@@ -155,6 +156,23 @@ function claudeResolvedPermissionPrompt(view: InteractivePrompt, decision: Provi
155
156
  };
156
157
  }
157
158
 
159
+ // #1173: AskUserQuestion's PreToolUse hook is answered with `deny` immediately (see
160
+ // control-server.ts) so Claude flushes its buffered reasoning instead of blocking on the human.
161
+ // The user's actual answer arrives afterward as a normal injected prompt, not as a hook
162
+ // response — this renders that prompt's text from the same view/decision shape the old
163
+ // blocking flow used, so the model sees a clear, structured hand-off instead of silence.
164
+ function claudeAnswerInjectionPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): string {
165
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
166
+ const questions = Array.isArray(view.questions) ? view.questions : Array.isArray(toolInput.questions) ? toolInput.questions : undefined;
167
+ const rows = resolvedQuestionRows(questions, decision.answers, "(no answer given)");
168
+ const qa = rows.map((row) => `Q: ${row.question}\nA: ${row.answer}`).join("\n\n");
169
+ return [
170
+ "You previously called AskUserQuestion with the following question(s); it was intercepted so the user could answer directly via Agent Relay instead of through the tool call:",
171
+ qa,
172
+ "The user has now answered above. Continue the task using their answer(s) — do not call AskUserQuestion again for the same question(s).",
173
+ ].join("\n\n");
174
+ }
175
+
158
176
  function claudeResolvedPermissionBody(input: { kind: string; title: string; toolName: string; promptBody: string; decisionLabel: string; questions?: unknown[]; answers?: Record<string, string> }): string {
159
177
  if (input.kind === "questions") {
160
178
  return [
@@ -1,5 +1,5 @@
1
1
  import type { Server, ServerWebSocket } from "bun";
2
- import type { Message, ReplyObligation } from "agent-relay-sdk";
2
+ import type { InteractivePrompt, Message, ReplyObligation } from "agent-relay-sdk";
3
3
  import { errMessage, isRecord } from "agent-relay-sdk";
4
4
  import type { ProviderPermissionDecisionInput, ProviderPermissionDecisionReason, ProviderPermissionPromptHandler, ProviderPermissionResolvedPrompt, ProviderStatusEvent, SemanticStatus, TerminalAttachSpec } from "./adapter";
5
5
  import { obligationRequiresExplicitReply } from "./reply-obligation-cache";
@@ -18,6 +18,11 @@ const PERMISSION_TIMEOUT_REASON =
18
18
  "No response received within ~15 minutes (timeout, not a dismissal). Re-ask if you still need a decision.";
19
19
  const PERMISSION_STOP_REASON = "The runner restarted while this permission request was pending; the original hook is no longer connected.";
20
20
  const RESOLVED_APPROVAL_LIMIT = 1024;
21
+ // #1173: the immediate PreToolUse response for a "questions" prompt (see the deny-then-hold
22
+ // branch in handlePermissionRequest below). Bridges the hand-off so the model doesn't read a
23
+ // bare denial and re-ask — the real answer follows shortly as an injected user prompt.
24
+ const PENDING_QUESTION_DENY_REASON =
25
+ "The user is answering this question directly via Agent Relay, not through this tool call — do not re-ask it. Their answer will follow as your next message; wait for it and continue from there.";
21
26
 
22
27
  // A hook that failed in a way it could not handle itself reports here so the
23
28
  // failure is never silent (#198 item 5). Phase 1 logs it FATAL to the per-agent
@@ -34,6 +39,12 @@ interface MonitorSocketData {
34
39
 
35
40
  type MonitorSocket = ServerWebSocket<MonitorSocketData>;
36
41
 
42
+ interface PendingPermissionWaiter {
43
+ resolve(input: ProviderPermissionDecisionInput): void;
44
+ timer: Timer;
45
+ suppressResolvedStatus?: boolean;
46
+ }
47
+
37
48
  export interface ControlServer {
38
49
  port: number;
39
50
  url: string;
@@ -52,6 +63,11 @@ interface ControlServerOptions {
52
63
  onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean; toolName?: string; toolInput?: unknown }): Promise<{ reasoningSettled: boolean } | void>;
53
64
  onPermissionResolved?(input: ResolvedPermissionPrompt): Promise<void> | void;
54
65
  onPermissionAbandoned?(input: PendingPermissionRequestRecord & { reason: string; decisionReason: ProviderPermissionDecisionReason }): Promise<void> | void;
66
+ // #1173: a "questions" (AskUserQuestion) prompt's own PreToolUse hook is answered with `deny`
67
+ // immediately (see the branch in handlePermissionRequest) instead of held open for the human,
68
+ // so there is no live HTTP request left to answer once the decision resolves. This delivers
69
+ // that resolution instead: `text` is the prompt to inject as a normal user turn.
70
+ onAnswerInject?(input: { approvalId: string; text?: string }): Promise<void> | void;
55
71
  // A provider user-prompt hook hands over text typed directly into the session
56
72
  // so the runner can mirror it into dashboard chat.
57
73
  onUserPrompt?(input: { prompt: string; transcriptPath?: string; promptId?: string }): Promise<void>;
@@ -78,7 +94,7 @@ export type ResolvedPermissionPrompt = ProviderPermissionResolvedPrompt;
78
94
  export function startControlServer(options: ControlServerOptions): ControlServer {
79
95
  const monitors = new Set<MonitorSocket>();
80
96
  const pendingDeliveries = new Map<string, { resolve(ids: number[]): void; timer: Timer }>();
81
- const pendingPermissionRequests = new Map<string, { resolve(input: ProviderPermissionDecisionInput): void; timer: Timer }>();
97
+ const pendingPermissionRequests = new Map<string, PendingPermissionWaiter>();
82
98
  // approvalIds already resolved, so a duplicate decision (e.g. bus replay after a
83
99
  // reconnect, or both the PreToolUse and PermissionRequest hooks racing one answer)
84
100
  // is an idempotent no-op instead of a silent command failure (#693).
@@ -213,17 +229,19 @@ export function startControlServer(options: ControlServerOptions): ControlServer
213
229
  bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, input.approvalId);
214
230
  recordResolvedApproval(resolvedApprovals, input.approvalId);
215
231
  pending.resolve(withDecisionReason(input, input.decision === "deny" || input.decision === "abort" ? "dismissed" : "answered"));
216
- options.onStatus({
217
- status: "busy",
218
- reason: "provider-turn",
219
- providerState: {
220
- state: "active",
221
- reason: "permissionResolved",
222
- label: options.permissionPromptHandler?.resolvedLabel ?? "Provider approval resolved",
223
- source: options.permissionPromptHandler?.source ?? options.providerSourceId ?? "provider",
224
- updatedAt: Date.now(),
225
- },
226
- });
232
+ if (!pending.suppressResolvedStatus) {
233
+ options.onStatus({
234
+ status: "busy",
235
+ reason: "provider-turn",
236
+ providerState: {
237
+ state: "active",
238
+ reason: "permissionResolved",
239
+ label: options.permissionPromptHandler?.resolvedLabel ?? "Provider approval resolved",
240
+ source: options.permissionPromptHandler?.source ?? options.providerSourceId ?? "provider",
241
+ updatedAt: Date.now(),
242
+ },
243
+ });
244
+ }
227
245
  return true;
228
246
  },
229
247
  };
@@ -292,26 +310,85 @@ function withDecisionReason(input: ProviderPermissionDecisionInput, kind: Provid
292
310
 
293
311
  async function handlePermissionRequest(
294
312
  req: Request,
295
- options: ControlServerOptions,
296
- pendingPermissionRequests: Map<string, { resolve(input: ProviderPermissionDecisionInput): void; timer: Timer }>,
313
+ options: ControlServerOptions,
314
+ pendingPermissionRequests: Map<string, PendingPermissionWaiter>,
297
315
  ): Promise<Response> {
298
316
  const handler = options.permissionPromptHandler;
299
317
  if (!handler) return Response.json({ ok: false, reason: "permission prompt handler unavailable" }, { status: 501 });
300
318
  const occurredAt = Date.now();
301
319
  const body = await req.json().catch(() => null);
302
320
  if (!isRecord(body)) return Response.json({});
303
- // #435/#1116: flush any un-mirrored narrative before the blocking form appears in
304
- // chat. The adapter owns how to settle its capture source; the transport only
305
- // forwards the hook payload as an anchor and stamps the returned truth value.
306
321
  const transcriptPath = typeof body.transcript_path === "string" ? body.transcript_path : "";
307
322
  const toolName = typeof body.tool_name === "string" ? body.tool_name : undefined;
308
323
  const toolInput = isRecord(body.tool_input) ? body.tool_input : undefined;
324
+ const approvalId = crypto.randomUUID();
325
+
326
+ // #1173: a PreToolUse "questions" prompt (the provider's AskUserQuestion) used to block this hook
327
+ // response for up to ~15 minutes waiting on the human — which meant the turn's reasoning that
328
+ // led to the question stayed buffered in the provider's process (never flushed to the transcript
329
+ // JSONL) for exactly as long as the human took to answer, so the dashboard could only ever
330
+ // show the question AFTER the reasoning that motivated it. Denying the tool call immediately
331
+ // is the ordinary, already-supported "rejected tool call" hook contract: the provider persists the
332
+ // buffered turn + a rejected tool_result right away, on its own, same as any other denied tool
333
+ // — so the settle-poll harvest below (previously doomed to time out for this one case) now
334
+ // finds the freshly-flushed anchor within moments. The human's eventual answer is delivered
335
+ // afterward as a normal injected prompt (see the `.then` below), not as this hook's response.
336
+ const probeView = handler.buildView(approvalId, body, true);
337
+ if (body.hook_event_name === "PreToolUse" && probeView.kind === "questions" && handler.buildAnswerInjectionPrompt) {
338
+ const response = Response.json(handler.buildHookResponse(
339
+ { approvalId, decision: "deny", reason: PENDING_QUESTION_DENY_REASON },
340
+ body,
341
+ ));
342
+ void awaitPermissionDecision({
343
+ options, pendingPermissionRequests, handler, approvalId, body, occurredAt, transcriptPath, toolName, toolInput,
344
+ blockedReason: "pendingQuestion",
345
+ recommendedAction: "Answer the question in Agent Relay.",
346
+ suppressResolvedStatus: true,
347
+ }).then(({ decision, view }) => {
348
+ const text = decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length
349
+ ? handler.buildAnswerInjectionPrompt!(view, decision, body)
350
+ : pendingQuestionDismissalPrompt(decision);
351
+ return options.onAnswerInject?.({ approvalId, text });
352
+ }).catch(() => {});
353
+ return response;
354
+ }
355
+
356
+ const { decision } = await awaitPermissionDecision({
357
+ options, pendingPermissionRequests, handler, approvalId, body, occurredAt, transcriptPath, toolName, toolInput,
358
+ blockedReason: "permissionRequest",
359
+ recommendedAction: "Review the permission request in Agent Relay.",
360
+ });
361
+ return Response.json(handler.buildHookResponse(decision, body));
362
+ }
363
+
364
+ // Shared by both the traditional block-until-decided flow and the #1173 deny-then-hold flow:
365
+ // pre-flushes narrative (#435/#1116), builds+persists the interactive view, publishes the
366
+ // blocked providerState, and resolves once a decision arrives (or the wait times out). Callers
367
+ // differ only in what happens with the resolved decision (build a hook response vs. inject an
368
+ // answer prompt), so that part is left to them.
369
+ async function awaitPermissionDecision(params: {
370
+ options: ControlServerOptions;
371
+ pendingPermissionRequests: Map<string, PendingPermissionWaiter>;
372
+ handler: ProviderPermissionPromptHandler;
373
+ approvalId: string;
374
+ body: Record<string, unknown>;
375
+ occurredAt: number;
376
+ transcriptPath: string;
377
+ toolName?: string;
378
+ toolInput?: unknown;
379
+ blockedReason: string;
380
+ recommendedAction: string;
381
+ suppressResolvedStatus?: boolean;
382
+ }): Promise<{ decision: ProviderPermissionDecisionInput; view: InteractivePrompt }> {
383
+ const { options, pendingPermissionRequests, handler, approvalId, body, occurredAt, transcriptPath, toolName, toolInput, blockedReason, recommendedAction, suppressResolvedStatus } = params;
384
+ // #435/#1116: flush any un-mirrored narrative before the blocking form appears in
385
+ // chat. The adapter owns how to settle its capture source; the transport only
386
+ // forwards the hook payload as an anchor and stamps the returned truth value.
309
387
  let reasoningSettled = true;
310
388
  if (transcriptPath && options.onSessionTurn) {
311
389
  const result = await options.onSessionTurn({ transcriptPath, isPreFlush: true, toolName, toolInput }).catch(() => undefined);
312
390
  if (result && typeof result.reasoningSettled === "boolean") reasoningSettled = result.reasoningSettled;
313
391
  }
314
- const approvalId = crypto.randomUUID();
315
392
  const view = handler.buildView(approvalId, body, reasoningSettled);
316
393
  const waitMs = options.permissionWaitMs ?? PERMISSION_WAIT_MS;
317
394
  const hookDeadlineAt = occurredAt + waitMs;
@@ -327,9 +404,9 @@ async function handlePermissionRequest(
327
404
  reason: "provider-turn",
328
405
  providerState: {
329
406
  state: "blocked",
330
- reason: "permissionRequest",
407
+ reason: blockedReason,
331
408
  label: view.title,
332
- recommendedAction: "Review the permission request in Agent Relay.",
409
+ recommendedAction,
333
410
  source: handler.source,
334
411
  pendingApproval: view,
335
412
  updatedAt: Date.now(),
@@ -342,14 +419,20 @@ async function handlePermissionRequest(
342
419
  bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, approvalId);
343
420
  resolve(withDecisionReason({ approvalId, decision: "deny", reason: PERMISSION_TIMEOUT_REASON }, "timeout"));
344
421
  }, waitMs);
345
- pendingPermissionRequests.set(approvalId, { resolve, timer });
422
+ pendingPermissionRequests.set(approvalId, { resolve, timer, suppressResolvedStatus });
346
423
  });
347
424
 
348
425
  if (decision.decisionReason?.kind !== "timeout" && options.onPermissionResolved) {
349
426
  await Promise.resolve(options.onPermissionResolved(handler.buildResolvedPrompt(view, decision, body, occurredAt))).catch(() => {});
350
427
  }
351
428
 
352
- return Response.json(handler.buildHookResponse(decision, body));
429
+ return { decision, view };
430
+ }
431
+
432
+ function pendingQuestionDismissalPrompt(decision: ProviderPermissionDecisionInput): string {
433
+ const timedOut = decision.decisionReason?.kind === "timeout";
434
+ const event = timedOut ? "timed out before the user answered" : "was dismissed before the user answered";
435
+ return `Your previous AskUserQuestion ${event}. Proceed on your best judgment, or re-ask if you still need a decision.`;
353
436
  }
354
437
 
355
438
  async function handleSessionTurn(req: Request, options: ControlServerOptions): Promise<Response> {
@@ -277,6 +277,12 @@ export class AgentRunner {
277
277
  // wakes one) so the blocked badge lifts on its own.
278
278
  private rateLimitHold?: ProviderState;
279
279
  private pendingRateLimitHold?: ProviderState;
280
+ // #1173: like rateLimitHold — a deny-then-hold "questions" (AskUserQuestion) prompt's own
281
+ // turn genuinely ends (the provider reacts to the denial and stops normally) before the human
282
+ // answers, so the pendingApproval can't ride the provider-turn active-work claim (it would
283
+ // vanish the instant that claim clears on the real idle). Persists across that idle; cleared
284
+ // once the answer is injected (or the prompt is dismissed/times out).
285
+ private pendingQuestionHold?: ProviderState;
280
286
  // Settle-poll cadence for the pre-flush anchor (#1116) — instance fields (not just the
281
287
  // module constants) so tests can shrink them without waiting out the real bound.
282
288
  private settlePollIntervalMs = SETTLE_POLL_INTERVAL_MS;
@@ -429,6 +435,7 @@ export class AgentRunner {
429
435
  onReplyObligations: () => Promise.resolve(this.obligationCache.get()),
430
436
  onSessionTurn: (input) => this.publishSessionTurn(input), onPermissionResolved: (input) => this.publishSessionEvent({ from: this.agentId, to: "user", body: input.body, occurredAt: input.occurredAt, session: { type: "prompt-resolution", origin: "provider", label: input.decisionLabel, ...(this.currentTurnId ? { turnId: this.currentTurnId } : {}), stepId: `prompt-resolution:${input.approvalId}`, promptResolution: input } }),
431
437
  onPermissionAbandoned: (input) => this.publishPermissionAbandonedNotice(input),
438
+ onAnswerInject: (input) => this.injectAnswerPrompt(input),
432
439
  onUserPrompt: (input) => this.handleUserPrompt(input),
433
440
  onSessionBoundary: (input) => this.handleSessionBoundary(input),
434
441
  onHookFatal: (report) => this.reportHookFatal(report),
@@ -926,6 +933,29 @@ export class AgentRunner {
926
933
  return { injected: true, messageId, attachmentCount: attachments.length };
927
934
  }
928
935
 
936
+ // #1173: the resolution side of the deny-then-hold AskUserQuestion flow (see control-server.ts's
937
+ // handlePermissionRequest). By the time this fires, the original PreToolUse hook has long since
938
+ // been answered with `deny` — there's no live HTTP request to respond to — so the human's answer
939
+ // (or dismissal) is delivered by clearing the pending-question hold and injecting a normal
940
+ // user prompt via the same path the dashboard chat box uses.
941
+ private async injectAnswerPrompt(input: { approvalId: string; text?: string }): Promise<void> {
942
+ const heldApprovalId = typeof this.pendingQuestionHold?.pendingApproval?.id === "string" ? this.pendingQuestionHold.pendingApproval.id : undefined;
943
+ if (heldApprovalId && heldApprovalId !== input.approvalId) return;
944
+ if (heldApprovalId === input.approvalId) {
945
+ this.pendingQuestionHold = undefined;
946
+ this.publishStatus();
947
+ }
948
+ const text = input.text ?? "The question was dismissed or timed out before the user answered. Proceed on your best judgment, or re-ask if you still need a decision.";
949
+ if (!this.process || !this.options.adapter.deliverInitialPrompt) {
950
+ this.sessionLog(`could not inject AskUserQuestion answer for ${input.approvalId}: provider process/prompt-injection unavailable`);
951
+ return;
952
+ }
953
+ this.recordInjectedPrompt(text.trim());
954
+ await this.options.adapter.deliverInitialPrompt(this.process, text).catch((error) => {
955
+ this.sessionLog(`failed to inject AskUserQuestion answer for ${input.approvalId}: ${errMessage(error)}`);
956
+ });
957
+ }
958
+
929
959
  private async promptInjectionText(body: string, messageId: number | undefined, attachments: Record<string, unknown>[]): Promise<string> {
930
960
  if (!attachments.length) return body;
931
961
  const messages = await messagesWithCachedAttachments([{
@@ -1251,10 +1281,18 @@ export class AgentRunner {
1251
1281
  this.rateLimitHold = update.providerState;
1252
1282
  if (fresh) this.publishRateLimitNotice(update.providerState);
1253
1283
  }
1284
+ } else if (ps.state === "blocked" && ps.reason === "pendingQuestion") {
1285
+ // #1173: set directly (never deferred) — unlike the rate-limit hold, this always
1286
+ // arrives while the turn that asked is still busy, well before the real idle it must
1287
+ // survive. Any other providerState (a fresh question, a plain approval, etc.) still
1288
+ // supersedes it via the `else` branch below.
1289
+ this.providerBlocked = true;
1290
+ this.pendingQuestionHold = update.providerState;
1254
1291
  } else {
1255
1292
  this.providerBlocked = ps.state === "blocked";
1256
1293
  this.pendingRateLimitHold = undefined;
1257
1294
  this.rateLimitHold = undefined;
1295
+ this.pendingQuestionHold = undefined;
1258
1296
  }
1259
1297
  } else if (status === "idle") {
1260
1298
  this.providerBlocked = false;
@@ -1313,6 +1351,12 @@ export class AgentRunner {
1313
1351
  this.currentTurnStartedAt = Date.now();
1314
1352
  this.compactionMidTurn = false;
1315
1353
  this.sessionLog(`turn started (turn ${this.currentTurnId})`);
1354
+ // #1173: a genuinely new turn starting (not the mid-turn busy update that sets the
1355
+ // hold itself — that arrives while currentTurnId is already set) means the human moved
1356
+ // on without using the form (a fresh message, a self-resume, etc.). The normal
1357
+ // answer/dismiss path already cleared this; this is the fallback for every other way
1358
+ // forward progress can happen, so a stale question never lingers past its own turn.
1359
+ this.pendingQuestionHold = undefined;
1316
1360
  }
1317
1361
  id = this.currentTurnId;
1318
1362
  this.busyReconciler.arm();
@@ -1904,7 +1948,7 @@ export class AgentRunner {
1904
1948
  const staleProviderTurnBusy = this.isStaleProviderTurnOnlyBusy(status, activeWork, busyReasons, liveness);
1905
1949
  const effectiveStatus = staleProviderTurnBusy ? "idle" : status;
1906
1950
  const agentStatus = runnerAgentStatus(effectiveStatus);
1907
- const providerState = terminalFailure?.providerState ?? this.rateLimitHold ?? (staleProviderTurnBusy ? null : providerStateFromActiveWork(activeWork));
1951
+ const providerState = terminalFailure?.providerState ?? this.rateLimitHold ?? this.pendingQuestionHold ?? (staleProviderTurnBusy ? null : providerStateFromActiveWork(activeWork));
1908
1952
  this.bus.setSemanticStatus(effectiveStatus === "offline" || effectiveStatus === "error" ? "idle" : effectiveStatus);
1909
1953
  this.bus.status({
1910
1954
  agentStatus,