larkin 0.3.3 → 0.4.2

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.
Files changed (32) hide show
  1. package/README.md +72 -33
  2. package/assets/readme/larkin-hero.svg +111 -0
  3. package/dist/agent/agent-state-store.cjs +321 -22
  4. package/dist/agent/agent-state-store.mjs +320 -22
  5. package/dist/agent/agent-transport.cjs +6 -4
  6. package/dist/agent/agent-transport.mjs +6 -4
  7. package/dist/agent/host-reminder-orchestrator.cjs +9 -3
  8. package/dist/agent/host-reminder-orchestrator.mjs +9 -3
  9. package/dist/agent/inbox-projection.cjs +114 -23
  10. package/dist/agent/inbox-projection.mjs +112 -23
  11. package/dist/app/cli.mjs +3 -1
  12. package/dist/app/local-control.mjs +108 -2
  13. package/dist/app/runtime-process.mjs +4 -0
  14. package/dist/app/session-cli.mjs +55 -20
  15. package/dist/feishu/document-comment.cjs +2 -1
  16. package/dist/feishu/document-comment.mjs +2 -1
  17. package/dist/feishu/host-business-state.cjs +7 -2
  18. package/dist/feishu/host-business-state.mjs +7 -2
  19. package/dist/feishu/host-shell.cjs +138 -32
  20. package/dist/feishu/host-shell.mjs +138 -32
  21. package/dist/runtime/pi-compaction-recovery.cjs +623 -0
  22. package/dist/runtime/pi-compaction-recovery.mjs +602 -0
  23. package/dist/runtime/pi-provider-config.cjs +2 -2
  24. package/dist/runtime/pi-provider-config.mjs +2 -2
  25. package/dist/runtime/pi-rpc-client.cjs +39 -22
  26. package/dist/runtime/pi-rpc-client.mjs +39 -22
  27. package/dist/runtime/provider-error-classifier.cjs +39 -0
  28. package/dist/runtime/provider-error-classifier.mjs +34 -0
  29. package/dist/runtime/runtime-adapters.mjs +82 -19
  30. package/dist/runtime/runtime-host.cjs +663 -12
  31. package/dist/runtime/runtime-host.mjs +661 -11
  32. package/package.json +1 -1
@@ -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,16 +1230,24 @@ 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
  }
1210
1244
  if (message.type === "session") {
1211
1245
  const record = agentStates.get(agent.agentId);
1212
1246
  if (record && record.state.sessions[message.runtime] !== message.sessionId) {
1213
- record.state.sessions[message.runtime] = message.sessionId;
1247
+ if (message.sessionId)
1248
+ record.state.sessions[message.runtime] = message.sessionId;
1249
+ else
1250
+ delete record.state.sessions[message.runtime];
1214
1251
  saveAgentState(record);
1215
1252
  log(`持久化 agent=${agent.name} runtime=${message.runtime} sessionId=${message.sessionId}`);
1216
1253
  }
@@ -1226,7 +1263,8 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
1226
1263
  hostState.recordStatusError(agent, `auth: ${readiness.reason}; ${readiness.nextAction}`);
1227
1264
  }
1228
1265
  else {
1229
- hostState.recordStatusError(agent, `${message.event.errorCategory || "provider"}: ${message.event.message}${message.event.nextAction ? `; ${message.event.nextAction}` : ""}`);
1266
+ const category = message.event.errorCategory || "provider";
1267
+ hostState.recordStatusError(agent, `${category}: Runtime input failed ${message.event.retryable ? "retryably" : "non-retryably"}; inspect delivery health and Runtime/provider configuration`);
1230
1268
  }
1231
1269
  }
1232
1270
  };
@@ -1357,6 +1395,74 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
1357
1395
  : unavailable ? "reset committed but Runtime/channel readiness is unavailable"
1358
1396
  : "reset committed but readiness did not converge before timeout" };
1359
1397
  },
1398
+ async recoverSession(agentId, reason, waitReadyMs = 30_000) {
1399
+ const agent = agents.find((candidate) => candidate.agentId === agentId);
1400
+ if (!agent)
1401
+ throw Object.assign(new Error(`未知 Agent: ${agentId}`), { code: "unknown_agent" });
1402
+ if (reason !== "context-overflow")
1403
+ throw Object.assign(new Error("unsupported recovery reason"), { code: "recovery_refused" });
1404
+ if (!runtimeHost.recoverSession)
1405
+ throw Object.assign(new Error("Runtime session recovery 尚未就绪"), { code: "recovery_unavailable" });
1406
+ const connectionState = (status = hostState.readStatus(agent)) => {
1407
+ const connectedAt = Date.parse(String(status.connectedAt || ""));
1408
+ const channelConnected = Number.isFinite(connectedAt) && connectedAt >= Date.parse(daemonStartedAt) - 1000;
1409
+ return { channelConnected, reconnecting: isChannelReconnecting(status) };
1410
+ };
1411
+ const readinessProjection = () => {
1412
+ const status = hostState.readStatus(agent);
1413
+ const turns = status.session && typeof status.session === "object"
1414
+ ? Math.max(0, Number(status.session.turns) || 0) : 0;
1415
+ const runtimeReady = Boolean(status.runtimeReadiness && typeof status.runtimeReadiness === "object"
1416
+ && status.runtimeReadiness.state === "ready");
1417
+ const pendingCount = stateStore(agent).withInboxTransaction(() => stateStore(agent).readNdjson("inbox").length);
1418
+ return { turns, runtimeReady, ...connectionState(status), pendingCount };
1419
+ };
1420
+ const initialProjection = readinessProjection();
1421
+ if (!initialProjection.channelConnected)
1422
+ throw Object.assign(new Error(`Agent ${agentId} channel is not connected`), { code: "channel_unavailable", ...initialProjection });
1423
+ if (initialProjection.reconnecting)
1424
+ throw Object.assign(new Error(`Agent ${agentId} channel is reconnecting`), { code: "channel_reconnecting", ...initialProjection });
1425
+ let recovery;
1426
+ try {
1427
+ recovery = await runtimeHost.recoverSession(agentId, reason);
1428
+ }
1429
+ catch (error) {
1430
+ throw Object.assign(error instanceof Error ? error : new Error(String(error)), readinessProjection());
1431
+ }
1432
+ const record = agentStates.get(agentId);
1433
+ try {
1434
+ if (record) {
1435
+ if (recovery.sessionId)
1436
+ record.state.sessions[agent.runtime] = recovery.sessionId;
1437
+ else
1438
+ delete record.state.sessions[agent.runtime];
1439
+ record.store.writeJson("agentState", record.state);
1440
+ }
1441
+ }
1442
+ catch (error) {
1443
+ const projection = readinessProjection();
1444
+ return { recoveryCommitted: true, generationChanged: recovery.generationChanged, sessionChanged: recovery.sessionChanged,
1445
+ ...projection, rearmedCount: recovery.rearmedCount, replayStatus: "pending", remainingPendingCount: projection.pendingCount,
1446
+ readyForFreshScenario: false, inboundObserved: false, code: "state_persistence_failed",
1447
+ error: "recovery committed but Agent session state persistence failed" };
1448
+ }
1449
+ const deadline = Date.now() + Math.max(0, waitReadyMs);
1450
+ let projection = readinessProjection();
1451
+ do {
1452
+ projection = readinessProjection();
1453
+ if (projection.turns === 0 && projection.runtimeReady && projection.channelConnected && !projection.reconnecting)
1454
+ break;
1455
+ if (Date.now() >= deadline)
1456
+ break;
1457
+ await new Promise((resolve) => setTimeout(resolve, Math.min(25, Math.max(1, deadline - Date.now()))));
1458
+ } while (true);
1459
+ const replayStatus = projection.pendingCount === 0 ? "consumed" : "pending";
1460
+ const ready = projection.turns === 0 && projection.runtimeReady && projection.channelConnected && !projection.reconnecting;
1461
+ return { recoveryCommitted: true, generationChanged: recovery.generationChanged, sessionChanged: recovery.sessionChanged,
1462
+ ...projection, rearmedCount: recovery.rearmedCount, replayStatus, remainingPendingCount: projection.pendingCount,
1463
+ readyForFreshScenario: ready, inboundObserved: false,
1464
+ ...(!ready ? { code: "recovery_timeout", error: "recovery committed but Runtime/channel readiness did not converge before timeout" } : {}) };
1465
+ },
1360
1466
  async start() {
1361
1467
  try {
1362
1468
  fs.mkdirSync(larkinHome, { recursive: true });