larkin 0.3.3 → 0.4.1

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.
@@ -160,6 +160,16 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
160
160
  return store;
161
161
  };
162
162
  const hostState = new HostStateProjection(stateStore, log);
163
+ const recordInboundDeliveryFailure = (agent, code) => {
164
+ const at = new Date().toISOString();
165
+ const reason = "Inbound Runtime delivery failed non-retryably; durable Inbox/ledger state is retained for recovery.";
166
+ const nextAction = "Inspect the delivery/status error, correct the Runtime or canonical Inbox state, then restart to replay safely.";
167
+ hostState.updateStatus(agent, {
168
+ inboundDeliveryHealth: { state: "error", code, at, reason, nextAction },
169
+ runtimeReadiness: { runtime: agent.runtime, state: "incompatible", reason, nextAction },
170
+ });
171
+ hostState.recordStatusError(agent, `${reason} ${nextAction} code=${code}`);
172
+ };
163
173
  const agentStates = new Map();
164
174
  const saveAgentState = (record) => {
165
175
  try {
@@ -295,6 +305,7 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
295
305
  return;
296
306
  }
297
307
  const telemetryMessageId = String(event.message_id || event.event_id || eventKey);
308
+ let canonicalInboxDurable = false;
298
309
  if (wake)
299
310
  telemetry?.beginMessage(agent.agentId, telemetryMessageId);
300
311
  if (event.event_id)
@@ -305,58 +316,76 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
305
316
  senderIdentity.ensureChatNames(agent, event.chat_id, 3_000),
306
317
  event._sender_is_bot ? Promise.resolve(null) : senderIdentity.ensureSenderSignature(agent, event.sender_id, 3_000),
307
318
  ]);
308
- const envelope = envelopeProjector.projectInbound(agent, event, { anchorReply: wake, names, signature });
309
- envelope.target = targetKeyOfInboxEnvelope({ ...envelope, chat_id: event.chat_id, thread_id: event.thread_id });
319
+ const projected = envelopeProjector.projectInbound(agent, event, { anchorReply: wake, names, signature });
320
+ projected.target = targetKeyOfInboxEnvelope({ ...projected, chat_id: event.chat_id, thread_id: event.thread_id });
310
321
  if (wake)
311
- envelope.wake = true;
312
- const inboxEnvelope = projectInboxEnvelope(envelope, {
322
+ projected.wake = true;
323
+ const candidate = projectInboxEnvelope(projected, {
313
324
  chat_id: event.chat_id,
314
325
  thread_id: event.thread_id,
315
326
  ...(event.create_time !== undefined ? { create_time: String(event.create_time) } : {}),
316
327
  sender_id: event.sender_id,
317
- content: String(envelope.content ?? event.content ?? ""),
328
+ content: String(projected.content ?? event.content ?? ""),
318
329
  });
330
+ let append;
319
331
  try {
320
- stateStore(agent).appendNdjson("inbox", inboxEnvelope);
332
+ append = stateStore(agent).appendCanonicalInboxOnce(candidate);
321
333
  }
322
334
  catch (error) {
323
335
  throw new Error(`inbox 写失败: ${errorMessage(error)}`);
324
336
  }
325
- // An event becomes permanently seen only after its canonical Inbox append
326
- // is durable. Failures remain eligible for same-process redelivery.
337
+ canonicalInboxDurable = true;
338
+ // An event becomes permanently transport-seen only after the canonical
339
+ // append/dedupe decision is durable. Agent model-seen state is untouched.
327
340
  if (event.event_id)
328
341
  seenEventIds.add(eventKey);
329
- hostState.appendConversation(agent, {
330
- direction: "in", from: envelope.sender_name, senderType: envelope.sender_type,
331
- target: targetFor(event).target, wake, text: event.content, messageId: envelope.message_id,
332
- at: envelope.timestamp || new Date().toISOString(),
333
- });
334
- return envelope;
342
+ if (append.status === "duplicate_consumed")
343
+ return null;
344
+ const inboxEnvelope = append.envelope;
345
+ if (append.status === "appended")
346
+ hostState.appendConversation(agent, {
347
+ direction: "in", from: inboxEnvelope.sender_name, senderType: inboxEnvelope.sender_type,
348
+ target: String(inboxEnvelope.target || targetFor(event).target), wake, text: event.content, messageId: inboxEnvelope.message_id,
349
+ at: inboxEnvelope.timestamp || new Date().toISOString(),
350
+ });
351
+ return inboxEnvelope;
335
352
  };
336
- const envelope = wake && telemetry
353
+ const inboxEnvelope = wake && telemetry
337
354
  ? await telemetry.phase(telemetryMessageId, "feishu.receive", SpanKind.CONSUMER, receive)
338
355
  : await receive();
339
- if (!wake)
356
+ if (!wake || !inboxEnvelope)
340
357
  return;
341
- const receipt = await runtimeHost.deliver(agent.agentId, envelope);
342
- if (receipt.status === "accepted" || receipt.status === "duplicate" || receipt.status === "deferred") {
358
+ const receipt = await runtimeHost.deliver(agent.agentId, inboxEnvelope);
359
+ if (receipt.status === "error") {
360
+ recordInboundDeliveryFailure(agent, "non_retryable_receipt");
343
361
  hostState.appendStatusLog(agent, "deliverLog", {
344
- from: envelope.sender_name,
345
- target: targetFor(event).target,
346
- excerpt: safeConversationExcerpt(event.content, 180),
347
- at: new Date().toISOString(),
348
- }, 30);
349
- if (envelope.sender_type === "human" || envelope.sender_type === "agent")
350
- processingEyes.add(agent, String(envelope.message_id || ""));
351
- if (receipt.status === "deferred")
352
- log(`Runtime 暂缓投递,消息保留在 inbox seq=${envelope.seq}: ${receipt.reason}`);
362
+ at: new Date().toISOString(), status: "error",
363
+ reason: "non-retryable Runtime delivery error; durable recovery state retained",
364
+ }, 80);
365
+ return;
353
366
  }
367
+ hostState.appendStatusLog(agent, "deliverLog", {
368
+ from: inboxEnvelope.sender_name,
369
+ target: String(inboxEnvelope.target || targetFor(event).target),
370
+ excerpt: safeConversationExcerpt(event.content, 180),
371
+ at: new Date().toISOString(),
372
+ }, 30);
373
+ if (inboxEnvelope.sender_type === "human" || inboxEnvelope.sender_type === "agent")
374
+ processingEyes.add(agent, String(inboxEnvelope.message_id || ""));
375
+ if (receipt.status === "deferred")
376
+ log(`Runtime 暂缓投递,消息保留在 inbox seq=${inboxEnvelope.seq}: ${receipt.reason}`);
354
377
  }
355
378
  catch (error) {
356
379
  if (wake)
357
380
  telemetry?.delivery(agent.agentId, telemetryMessageId, "error");
358
- log(`onFeishuMessage 异常 agent=${agent.name}: ${error instanceof Error ? error.stack || error.message : String(error)}`);
359
- hostState.recordStatusError(agent, `onFeishuMessage: ${errorMessage(error)}`);
381
+ if (canonicalInboxDurable) {
382
+ log(`onFeishuMessage Runtime delivery failed agent=${agent.name}; canonical Inbox retained`);
383
+ recordInboundDeliveryFailure(agent, "runtime_delivery_exception");
384
+ }
385
+ else {
386
+ log(`onFeishuMessage 异常 agent=${agent.name}: ${error instanceof Error ? error.stack || error.message : String(error)}`);
387
+ hostState.recordStatusError(agent, `onFeishuMessage: ${errorMessage(error)}`);
388
+ }
360
389
  }
361
390
  finally {
362
391
  if (event.event_id)
@@ -1201,9 +1230,14 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
1201
1230
  return;
1202
1231
  }
1203
1232
  if (message.type === "delivery") {
1233
+ if (message.status === "error")
1234
+ recordInboundDeliveryFailure(agent, "runtime_delivery_event");
1204
1235
  hostState.appendStatusLog(agent, "deliverLog", {
1205
1236
  at: new Date().toISOString(), deliveryId: message.deliveryId, messageId: message.messageId,
1206
- status: message.status, ...(message.reason ? { reason: safeConversationExcerpt(message.reason, 120) } : {}),
1237
+ status: message.status,
1238
+ ...(message.status === "error"
1239
+ ? { reason: "non-retryable Runtime delivery error; durable recovery state retained" }
1240
+ : (message.reason ? { reason: safeConversationExcerpt(message.reason, 120) } : {})),
1207
1241
  }, 80);
1208
1242
  return;
1209
1243
  }
@@ -1226,7 +1260,8 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
1226
1260
  hostState.recordStatusError(agent, `auth: ${readiness.reason}; ${readiness.nextAction}`);
1227
1261
  }
1228
1262
  else {
1229
- hostState.recordStatusError(agent, `${message.event.errorCategory || "provider"}: ${message.event.message}${message.event.nextAction ? `; ${message.event.nextAction}` : ""}`);
1263
+ const category = message.event.errorCategory || "provider";
1264
+ hostState.recordStatusError(agent, `${category}: Runtime input failed ${message.event.retryable ? "retryably" : "non-retryably"}; inspect delivery health and Runtime/provider configuration`);
1230
1265
  }
1231
1266
  }
1232
1267
  };
@@ -9,6 +9,7 @@ const node_crypto_1 = __importDefault(require("node:crypto"));
9
9
  const node_fs_1 = __importDefault(require("node:fs"));
10
10
  const node_path_1 = __importDefault(require("node:path"));
11
11
  const agent_cli_capabilities_js_1 = require("../agent/agent-cli-capabilities.cjs");
12
+ const inbox_projection_js_1 = require("../agent/inbox-projection.cjs");
12
13
  const api_1 = require("@opentelemetry/api");
13
14
  const runtime_readiness_js_1 = require("./runtime-readiness.cjs");
14
15
  const official_lark_cli_js_1 = require("../app/official-lark-cli.cjs");
@@ -27,6 +28,20 @@ exports.RuntimeSessionResetError = RuntimeSessionResetError;
27
28
  const MAX_DELIVERIES = 2048;
28
29
  const now = () => new Date().toISOString();
29
30
  const isActiveDelivery = (status) => ["pending", "submitting", "accepted"].includes(status);
31
+ const REPLAY_FAILURE_CODES = new Set([
32
+ "canonical_inbox_row_missing", "canonical_inbox_malformed", "duplicate_message_id",
33
+ "inbox_state_conflict", "delivery_target_conflict", "structured_target_invalid",
34
+ "structured_target_missing", "wake_reason_conflict",
35
+ ]);
36
+ function replayFailureReason(code) {
37
+ return `Runtime delivery quarantined (${code}): no safe canonical Inbox target is available; the Inbox/ledger remain durable for operator recovery`;
38
+ }
39
+ function replayFailureCodeOf(record) {
40
+ if (record.status !== "error" || typeof record.reason !== "string")
41
+ return null;
42
+ const code = /^Runtime delivery quarantined \(([^)]+)\):/.exec(record.reason)?.[1];
43
+ return code && REPLAY_FAILURE_CODES.has(code) ? code : null;
44
+ }
30
45
  function deliveryFile(agent) {
31
46
  return agent.config.stateDir ? node_path_1.default.join(agent.config.stateDir, "runtime-deliveries.json") : null;
32
47
  }
@@ -159,6 +174,81 @@ function createRuntimeHost(options) {
159
174
  emitConsumed(agent, persist(agent));
160
175
  return agent.records.get(record.deliveryId) ?? record;
161
176
  };
177
+ const scrubQuarantinedInput = (record, reason) => {
178
+ const staleInput = record.input && typeof record.input === "object" ? record.input : null;
179
+ record.input = {
180
+ inputId: typeof staleInput?.inputId === "string" && staleInput.inputId ? staleInput.inputId : record.deliveryId,
181
+ deliveryId: record.deliveryId,
182
+ kind: "wake",
183
+ text: reason,
184
+ attempt: Number.isSafeInteger(staleInput?.attempt) && Number(staleInput?.attempt) >= 0 ? Number(staleInput.attempt) : 0,
185
+ };
186
+ };
187
+ /** Rebuild every submitted notice from canonical structured state, never persisted text. */
188
+ const prepareRecordInput = (agent, record, busy) => {
189
+ if (record.target !== undefined && (typeof record.target !== "string" || !(0, inbox_projection_js_1.isCanonicalInboxTarget)(record.target))) {
190
+ return { status: "error", code: "structured_target_invalid", reason: replayFailureReason("structured_target_invalid") };
191
+ }
192
+ if (record.wakeReason !== undefined && typeof record.wakeReason !== "string") {
193
+ return { status: "error", code: "wake_reason_conflict", reason: replayFailureReason("wake_reason_conflict") };
194
+ }
195
+ let target = typeof record.target === "string" ? record.target : null;
196
+ let wakeReason = typeof record.wakeReason === "string" ? record.wakeReason : undefined;
197
+ if (agent.stateStore?.resolveInboxDeliverySource) {
198
+ let source;
199
+ try {
200
+ source = agent.stateStore.resolveInboxDeliverySource(record.messageId);
201
+ }
202
+ catch {
203
+ source = { status: "invalid", code: "canonical_inbox_malformed" };
204
+ }
205
+ if (source.status === "consumed")
206
+ return { status: "consumed" };
207
+ if (source.status === "missing" || source.status === "invalid") {
208
+ return { status: "error", code: source.code, reason: replayFailureReason(source.code) };
209
+ }
210
+ if (target && target !== source.target) {
211
+ return { status: "error", code: "delivery_target_conflict", reason: replayFailureReason("delivery_target_conflict") };
212
+ }
213
+ const canonicalWakeReason = typeof source.envelope.wake_reason === "string" ? source.envelope.wake_reason : undefined;
214
+ if (record.wakeReason !== undefined && wakeReason !== canonicalWakeReason) {
215
+ return { status: "error", code: "wake_reason_conflict", reason: replayFailureReason("wake_reason_conflict") };
216
+ }
217
+ target = source.target;
218
+ wakeReason = canonicalWakeReason;
219
+ }
220
+ if (!target)
221
+ return { status: "error", code: "structured_target_missing", reason: replayFailureReason("structured_target_missing") };
222
+ const staleInput = record.input && typeof record.input === "object" ? record.input : null;
223
+ const attempt = Number.isSafeInteger(staleInput?.attempt) && Number(staleInput?.attempt) >= 0 ? Number(staleInput.attempt) : 0;
224
+ const inputId = typeof staleInput?.inputId === "string" && staleInput.inputId ? staleInput.inputId : record.deliveryId;
225
+ record.target = target;
226
+ if (wakeReason)
227
+ record.wakeReason = wakeReason;
228
+ else
229
+ delete record.wakeReason;
230
+ record.input = {
231
+ inputId,
232
+ deliveryId: record.deliveryId,
233
+ kind: busy ? "inbox_update" : "wake",
234
+ text: options.promptBuilder.buildInboxNotice({ busy, count: 1, deliveryId: record.deliveryId, target,
235
+ ...(wakeReason ? { wakeReason } : {}) }),
236
+ attempt,
237
+ };
238
+ return { status: "ready", target };
239
+ };
240
+ const quarantineRecord = (agent, record, code) => {
241
+ const reason = replayFailureReason(code);
242
+ scrubQuarantinedInput(record, reason);
243
+ record.reason = reason;
244
+ record.retryable = false;
245
+ const finalRecord = setRecord(agent, record, "error");
246
+ if (finalRecord.status === "consumed")
247
+ return { status: "accepted", deliveryId: record.deliveryId };
248
+ emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: record.deliveryId,
249
+ messageId: record.messageId, status: "error", reason });
250
+ return { status: "error", deliveryId: record.deliveryId, reason, retryable: false };
251
+ };
162
252
  const reconcileExternalConsumption = (agent) => {
163
253
  if (agent.stateStore) {
164
254
  agent.stateStore.withInboxTransaction(() => reconcileFile(agent, agent.stateStore.readJson("runtimeDeliveries", { version: 1, records: [] })));
@@ -315,6 +405,20 @@ function createRuntimeHost(options) {
315
405
  if (!busy)
316
406
  agent.busy = true; // Reserve the turn before prompt() can yield.
317
407
  try {
408
+ const prepared = prepareRecordInput(agent, record, busy);
409
+ if (prepared.status === "consumed") {
410
+ if (!busy)
411
+ agent.busy = false;
412
+ const consumedRecord = setRecord(agent, record, "consumed");
413
+ emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: consumedRecord.deliveryId,
414
+ messageId: consumedRecord.messageId, status: "consumed" });
415
+ return { status: "accepted", deliveryId: record.deliveryId };
416
+ }
417
+ if (prepared.status === "error") {
418
+ if (!busy)
419
+ agent.busy = false;
420
+ return quarantineRecord(agent, record, prepared.code);
421
+ }
318
422
  const session = await ensureSession(agent);
319
423
  const submittingRecord = setRecord(agent, record, "submitting");
320
424
  if (submittingRecord.status === "consumed") {
@@ -807,7 +911,14 @@ function createRuntimeHost(options) {
807
911
  const deliveryFile = stateStore.readJson("runtimeDeliveries", { version: 1, records: [] });
808
912
  if (!stateStore.readNdjson)
809
913
  return deliveryFile;
810
- const inboxIds = new Set(stateStore.readNdjson("inbox").flatMap((row) => typeof row?.message_id === "string" ? [row.message_id] : []));
914
+ let inboxRows;
915
+ try {
916
+ inboxRows = stateStore.readNdjson("inbox");
917
+ }
918
+ catch {
919
+ return deliveryFile;
920
+ }
921
+ const inboxIds = new Set(inboxRows.flatMap((row) => typeof row?.message_id === "string" ? [row.message_id] : []));
811
922
  let changed = false;
812
923
  const records = deliveryFile.records.map((record) => {
813
924
  const synthetic = /^(?:redeliver_|rem_|interaction_)/.test(record.messageId);
@@ -830,11 +941,34 @@ function createRuntimeHost(options) {
830
941
  stabilityTimer: null, recreateReason: null, stopped: false, disabledReason: null,
831
942
  configurationRecovery: null, stateStore, readiness: null,
832
943
  turnInProgress: false, turnHadFailure: false, turnHadAuthenticatedOutput: false, authFailureActive: false };
833
- for (const record of agent.records.values())
834
- if (isActiveDelivery(record.status))
835
- record.status = "pending";
836
- emitConsumed(agent, persist(agent));
944
+ const startupConsumed = [];
945
+ const startupQuarantined = [];
946
+ for (const record of agent.records.values()) {
947
+ const existingQuarantine = replayFailureCodeOf(record);
948
+ if (existingQuarantine) {
949
+ startupQuarantined.push({ record, code: existingQuarantine });
950
+ continue;
951
+ }
952
+ if (!isActiveDelivery(record.status))
953
+ continue;
954
+ record.status = "pending";
955
+ const prepared = prepareRecordInput(agent, record, false);
956
+ if (prepared.status === "consumed") {
957
+ record.status = "consumed";
958
+ record.updatedAt = now();
959
+ startupConsumed.push(record);
960
+ }
961
+ else if (prepared.status === "error") {
962
+ record.status = "error";
963
+ record.updatedAt = now();
964
+ scrubQuarantinedInput(record, prepared.reason);
965
+ record.reason = prepared.reason;
966
+ record.retryable = false;
967
+ startupQuarantined.push({ record, code: prepared.code });
968
+ }
969
+ }
837
970
  managed.set(config.agentId, agent);
971
+ emitConsumed(agent, [...startupConsumed, ...persist(agent)]);
838
972
  try {
839
973
  await ensureSession(agent);
840
974
  activeCount += 1;
@@ -852,6 +986,13 @@ function createRuntimeHost(options) {
852
986
  emit({ type: "agent-status", agentId: config.agentId, status: "error", error: reason,
853
987
  ...(error instanceof runtime_readiness_js_1.RuntimePrerequisiteError ? { readiness: error.readiness } : {}) });
854
988
  }
989
+ const visibleQuarantines = startupQuarantined.filter(({ record }) => agent.records.get(record.deliveryId)?.status === "error");
990
+ for (const { record, code } of visibleQuarantines)
991
+ emit({ type: "delivery", agentId: config.agentId,
992
+ deliveryId: record.deliveryId, messageId: record.messageId, status: "error", reason: replayFailureReason(code) });
993
+ if (visibleQuarantines.length)
994
+ emit({ type: "agent-status", agentId: config.agentId, status: "error",
995
+ error: `${visibleQuarantines.length} Runtime delivery record(s) quarantined; canonical Inbox recovery is required` });
855
996
  agent.poller = setInterval(() => reconcileExternalConsumption(agent), 250);
856
997
  agent.poller.unref?.();
857
998
  });
@@ -867,12 +1008,33 @@ function createRuntimeHost(options) {
867
1008
  telemetry?.delivery(agentId, messageId, "error");
868
1009
  throw new Error(`unknown runtime Agent: ${agentId}`);
869
1010
  }
1011
+ let target;
1012
+ try {
1013
+ target = (0, inbox_projection_js_1.targetKeyOfInboxEnvelope)(envelope);
1014
+ }
1015
+ catch (error) {
1016
+ const reason = `Inbox target derivation failed: ${error instanceof Error ? error.message : String(error)}`;
1017
+ const deliveryId = `invalid-target-${node_crypto_1.default.createHash("sha256").update(`${agentId}\0${messageId}`).digest("hex").slice(0, 24)}`;
1018
+ telemetry?.delivery(agentId, messageId, "error");
1019
+ emit({ type: "delivery", agentId, deliveryId, messageId, status: "error", reason });
1020
+ return { status: "error", deliveryId, reason, retryable: false };
1021
+ }
870
1022
  reconcileExternalConsumption(agent);
871
1023
  const existingId = agent.byMessage.get(messageId);
872
1024
  if (existingId) {
873
1025
  const existing = agent.records.get(existingId);
874
1026
  if (existing?.status === "error") {
875
- existing.input.attempt += 1;
1027
+ if (existing.target && existing.target !== target)
1028
+ return quarantineRecord(agent, existing, "delivery_target_conflict");
1029
+ existing.target = target;
1030
+ if (typeof envelope.wake_reason === "string" && envelope.wake_reason)
1031
+ existing.wakeReason = envelope.wake_reason;
1032
+ else
1033
+ delete existing.wakeReason;
1034
+ const priorAttempt = existing.input && Number.isSafeInteger(existing.input.attempt) ? existing.input.attempt : 0;
1035
+ existing.input = existing.input && typeof existing.input === "object"
1036
+ ? { ...existing.input, attempt: priorAttempt + 1 }
1037
+ : { inputId: existing.deliveryId, deliveryId: existing.deliveryId, kind: "wake", text: "", attempt: priorAttempt + 1 };
876
1038
  delete existing.reason;
877
1039
  delete existing.retryable;
878
1040
  setRecord(agent, existing, "pending");
@@ -893,11 +1055,12 @@ function createRuntimeHost(options) {
893
1055
  }
894
1056
  const deliveryId = node_crypto_1.default.randomUUID();
895
1057
  const busy = agent.busy || agent.submitting;
1058
+ const wakeReason = typeof envelope.wake_reason === "string" ? envelope.wake_reason : undefined;
896
1059
  const input = { inputId: deliveryId, deliveryId, kind: busy ? "inbox_update" : "wake",
897
- text: options.promptBuilder.buildInboxNotice({ busy, count: 1, deliveryId,
898
- ...(typeof envelope.target === "string" ? { target: envelope.target } : {}),
899
- ...(typeof envelope.wake_reason === "string" ? { wakeReason: envelope.wake_reason } : {}) }), attempt: 0 };
900
- const record = { deliveryId, messageId, status: "pending", input, updatedAt: now() };
1060
+ text: options.promptBuilder.buildInboxNotice({ busy, count: 1, deliveryId, target,
1061
+ ...(wakeReason ? { wakeReason } : {}) }), attempt: 0 };
1062
+ const record = { deliveryId, messageId, status: "pending", input, target,
1063
+ ...(wakeReason ? { wakeReason } : {}), updatedAt: now() };
901
1064
  agent.records.set(deliveryId, record);
902
1065
  agent.byMessage.set(messageId, deliveryId);
903
1066
  persist(agent);