larkin 0.4.13 → 0.4.14

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.
@@ -302,31 +302,50 @@ export class HostEnvelopeProjector {
302
302
  }
303
303
  createReminderEnvelope(agentId, reminder, overdueMs, repeatDescription) {
304
304
  const seq = this.nextSequence(agentId);
305
- const anchorMessageId = typeof reminder.msgRef === "string" && /^om_[A-Za-z0-9_-]+$/.test(reminder.msgRef)
306
- ? reminder.msgRef
307
- : null;
305
+ const legacyChannel = typeof reminder.channel === "string" ? reminder.channel.trim() : "";
306
+ const legacyChatTarget = /^oc_[A-Za-z0-9_-]+$/.test(legacyChannel) ? `chat:${legacyChannel}` : null;
307
+ const deliveryTarget = typeof reminder.deliveryTarget === "string" && reminder.deliveryTarget
308
+ ? reminder.deliveryTarget : legacyChatTarget;
309
+ const anchorMessageId = typeof reminder.deliveryAnchor === "string" && /^om_[A-Za-z0-9_-]+$/.test(reminder.deliveryAnchor)
310
+ ? reminder.deliveryAnchor
311
+ : typeof reminder.msgRef === "string" && /^om_[A-Za-z0-9_-]+$/.test(reminder.msgRef)
312
+ ? reminder.msgRef
313
+ : null;
314
+ const commentAnchorId = deliveryTarget?.startsWith("document-comment:") && typeof reminder.deliveryAnchor === "string"
315
+ && /^doc_comment_[A-Za-z0-9_-]+$/.test(reminder.deliveryAnchor) ? reminder.deliveryAnchor : null;
316
+ const anchorId = anchorMessageId || commentAnchorId;
317
+ const chatId = deliveryTarget?.match(/^chat:(oc_[A-Za-z0-9_-]+)$/)?.[1] || null;
308
318
  const lines = [
309
319
  `[定时提醒触发] ${reminder.title}`,
310
320
  `提醒ID: #${reminder.reminderId.slice(0, 8)}` + (reminder.repeat && repeatDescription
311
321
  ? ` 重复: ${repeatDescription}(下次已自动排在 ${reminder.fireAt})`
312
322
  : " 类型: 一次性"),
313
323
  overdueMs > 120_000 ? `注意: 原定时间已过 ${Math.round(overdueMs / 60_000)} 分钟(Runtime Host 离线期间错过,现补触发)` : null,
314
- anchorMessageId ? `锚定消息: ${anchorMessageId}` : reminder.msgRef ? `历史锚点 ${String(reminder.msgRef)} 不是飞书 om_ message_id,不能用于回复` : null,
324
+ deliveryTarget ? `原始 deliveryTarget: ${deliveryTarget}`
325
+ : anchorId ? "这是升级前存量 user-facing reminder,优先回复其安全锚点;不得向标题中的任何人或第三方发送消息"
326
+ : "本条为 internal/no-delivery reminder,不得向标题中的任何人或第三方发送消息",
327
+ anchorId ? `锚定消息: ${anchorId}` : reminder.msgRef ? `历史锚点 ${String(reminder.msgRef)} 不是可用的 delivery anchor,不能用于回复` : null,
315
328
  anchorMessageId
316
- ? `回复原会话: ${this.larkCommand(`im +messages-reply --message-id ${anchorMessageId} ...`)}`
317
- : null,
318
- reminder.channel && /^oc_[A-Za-z0-9_-]+$/.test(reminder.channel)
319
- ? `发送到原群: ${this.larkCommand(`im +messages-send --chat-id ${reminder.channel} ...`)}`
320
- : reminder.channel
321
- ? `历史目标 ${reminder.channel} 不是 chat_id;${anchorMessageId ? "若不回复锚定消息," : ""}先用 ${this.larkCommand("im +chat-search")} 查询并确认 oc_ chat_id,禁止按名称猜测发送目标`
322
- : anchorMessageId
323
- ? null
324
- : `本条存量提醒缺少可用的飞书 message_id/chat_id,无法安全推断原会话;请先用 ${this.larkCommand("im +chat-search")} 确认目标,禁止猜测发送`,
329
+ ? [
330
+ `回复原会话: ${this.larkCommand(`im +messages-reply --message-id ${anchorMessageId}${deliveryTarget?.startsWith("thread:") ? " --reply-in-thread" : ""} ...`)}`,
331
+ chatId ? `若 ${anchorMessageId} 无法由当前 Inbox 解析(例如 interaction_* 卡片锚点或已淘汰的历史消息),改用聊天兜底发送: ${this.larkCommand(`im +messages-send --chat-id ${chatId} ...`)};仅使用这个持久化 chat_id,不得猜测收件人` : null,
332
+ ].filter((line) => Boolean(line)).join("\n")
333
+ : commentAnchorId
334
+ ? `回复原文档评论: ${this.agentCommand(`comment reply --message-id ${commentAnchorId} --text ...`)}`
335
+ : deliveryTarget
336
+ ? chatId
337
+ ? `发送到原始 target: ${deliveryTarget}: ${this.larkCommand(`im +messages-send --chat-id ${chatId} ...`)}(不得从提醒标题推断收件人)`
338
+ : `发送到原始 target: ${deliveryTarget}(不得从提醒标题推断收件人)`
339
+ : null,
325
340
  `这是你之前用 ${this.agentCommand("reminder schedule")} 设置的提醒,请按标题执行相应动作。管理: ${this.agentCommand("reminder list")} / ${this.agentCommand("reminder snooze")} / ${this.agentCommand("reminder cancel")}`,
326
341
  ].filter((line) => Boolean(line));
327
342
  const envelope = {
328
343
  kind: "reminder",
329
- message_id: `rem_${reminder.reminderId.slice(0, 16)}_${seq}`,
344
+ // The random suffix keeps each firing's occurrence identity unique across
345
+ // Host restarts: the in-memory seq restarts at 1 and would otherwise let a
346
+ // recurring reminder reuse a prior occurrenceId in the delivery audit.
347
+ message_id: `rem_${reminder.reminderId.slice(0, 16)}_${seq}_${this.randomHex(6)}`,
348
+ reminderId: reminder.reminderId,
330
349
  seq,
331
350
  sender_name: "定时提醒",
332
351
  sender_type: "system",
@@ -336,6 +355,8 @@ export class HostEnvelopeProjector {
336
355
  timestamp: this.now().toISOString(),
337
356
  thread_id: null,
338
357
  wake: true,
358
+ deliveryTarget,
359
+ deliveryAnchor: anchorId,
339
360
  };
340
361
  return { ...envelope, target: RUNTIME_REMINDER_TARGET };
341
362
  }
@@ -93,6 +93,8 @@ class HostInteractionOrchestrator {
93
93
  const reminderId = `int_${(0, node_crypto_1.createHash)("sha256").update(run.run_id).digest("hex").slice(0, 24)}`;
94
94
  const current = this.now();
95
95
  const fireAt = effect.args.fire_at ? Date.parse(effect.args.fire_at) : current + Number(effect.args.delay_seconds) * 1_000;
96
+ const deliveryTarget = typeof run.chat_id === "string" && run.chat_id ? `chat:${run.chat_id}` : null;
97
+ const deliveryAnchor = typeof run.message_id === "string" && /^om_[A-Za-z0-9_-]+$/.test(run.message_id) ? run.message_id : null;
96
98
  const reminder = ReminderStore.mutate(file, (store) => {
97
99
  const existing = store.reminders.find((candidate) => candidate.reminderId === reminderId);
98
100
  if (existing)
@@ -107,6 +109,9 @@ class HostInteractionOrchestrator {
107
109
  status: "scheduled",
108
110
  msgRef: run.message_id,
109
111
  msgPermalink: null,
112
+ deliveryTarget,
113
+ deliveryAnchor,
114
+ deliveryMode: "user",
110
115
  repeat: null,
111
116
  tz: null,
112
117
  channel: run.chat_id,
@@ -114,7 +119,9 @@ class HostInteractionOrchestrator {
114
119
  version: 1,
115
120
  events: [],
116
121
  };
117
- ReminderStore.appendEvent(created, "scheduled", "interaction", run.run_id, created.fireAt, current);
122
+ ReminderStore.appendEvent(created, "scheduled", "interaction", run.run_id, created.fireAt, current, {
123
+ deliveryTarget, deliveryAnchor, deliveryMode: "user",
124
+ });
118
125
  store.reminders.push(created);
119
126
  return created;
120
127
  }, Math.max(1, Math.min(350, deadline - this.now() - 100)));
@@ -57,6 +57,8 @@ export class HostInteractionOrchestrator {
57
57
  const reminderId = `int_${createHash("sha256").update(run.run_id).digest("hex").slice(0, 24)}`;
58
58
  const current = this.now();
59
59
  const fireAt = effect.args.fire_at ? Date.parse(effect.args.fire_at) : current + Number(effect.args.delay_seconds) * 1_000;
60
+ const deliveryTarget = typeof run.chat_id === "string" && run.chat_id ? `chat:${run.chat_id}` : null;
61
+ const deliveryAnchor = typeof run.message_id === "string" && /^om_[A-Za-z0-9_-]+$/.test(run.message_id) ? run.message_id : null;
60
62
  const reminder = ReminderStore.mutate(file, (store) => {
61
63
  const existing = store.reminders.find((candidate) => candidate.reminderId === reminderId);
62
64
  if (existing)
@@ -71,6 +73,9 @@ export class HostInteractionOrchestrator {
71
73
  status: "scheduled",
72
74
  msgRef: run.message_id,
73
75
  msgPermalink: null,
76
+ deliveryTarget,
77
+ deliveryAnchor,
78
+ deliveryMode: "user",
74
79
  repeat: null,
75
80
  tz: null,
76
81
  channel: run.chat_id,
@@ -78,7 +83,9 @@ export class HostInteractionOrchestrator {
78
83
  version: 1,
79
84
  events: [],
80
85
  };
81
- ReminderStore.appendEvent(created, "scheduled", "interaction", run.run_id, created.fireAt, current);
86
+ ReminderStore.appendEvent(created, "scheduled", "interaction", run.run_id, created.fireAt, current, {
87
+ deliveryTarget, deliveryAnchor, deliveryMode: "user",
88
+ });
82
89
  store.reminders.push(created);
83
90
  return created;
84
91
  }, Math.max(1, Math.min(350, deadline - this.now() - 100)));
@@ -155,16 +155,30 @@ function createOutboundTransport(options) {
155
155
  };
156
156
  const handleSend = (body) => {
157
157
  const target = body.target || "";
158
+ const targetKey = String(target);
159
+ const report = (succeeded, details = {}) => {
160
+ if (options.dryRun)
161
+ return;
162
+ try {
163
+ options.onDeliveryOutcome?.({ target: targetKey, succeeded, ...details });
164
+ }
165
+ catch (error) {
166
+ options.log("delivery outcome hook 失败:", error.message);
167
+ }
168
+ };
158
169
  const content = String(body.content ?? "").replace(/\n+$/, "");
159
170
  const chatId = options.resolveChatId(target);
160
171
  if (!chatId) {
161
- return { ok: false, status: 400, error: `该 target 没有已知 chat_id:${JSON.stringify(target)}。只能回复 canonical Inbox 给出的确切会话;不会兜底发往默认群。` };
172
+ const error = `该 target 没有已知 chat_id:${JSON.stringify(target)}。只能回复 canonical Inbox 给出的确切会话;不会兜底发往默认群。`;
173
+ report(false, { reason: error });
174
+ return { ok: false, status: 400, error };
162
175
  }
163
176
  const idempotencyKey = body.idempotencyKey
164
177
  || node_crypto_1.default.createHash("sha256").update(`${String(target)}\0${content}`).digest("hex").slice(0, 32);
165
178
  const replyContext = options.replyContextFor(String(target)) || options.replyContextFor(chatId);
166
179
  const attachmentIds = Array.isArray(body.attachmentIds) ? body.attachmentIds.filter(Boolean) : [];
167
180
  let messageId = "sent";
181
+ let committedProviderCalls = 0;
168
182
  if (content !== "" || attachmentIds.length === 0) {
169
183
  const replyInThread = Boolean(replyContext?.in_topic && replyContext.reply_to);
170
184
  const result = replyInThread
@@ -172,7 +186,9 @@ function createOutboundTransport(options) {
172
186
  : options.sendText(chatId, content, idempotencyKey);
173
187
  if (result.code !== 0) {
174
188
  options.log("send 失败 FULL:", `${result.stdout || ""} || ${result.stderr || ""}`);
175
- return { ok: false, status: 502, error: `lark-cli send failed: ${(result.stdout || result.stderr || "").trim().split("\n")[0]}` };
189
+ const error = `lark-cli send failed: ${(result.stdout || result.stderr || "").trim().split("\n")[0]}`;
190
+ report(false, { reason: error });
191
+ return { ok: false, status: 502, error };
176
192
  }
177
193
  try {
178
194
  const parsed = JSON.parse(result.stdout);
@@ -181,8 +197,10 @@ function createOutboundTransport(options) {
181
197
  catch {
182
198
  messageId = options.dryRun ? "dryrun" : "sent";
183
199
  }
200
+ committedProviderCalls += 1;
184
201
  options.log(`${replyInThread ? "reply-in-thread" : "send"} → chat=${chatId} len=${content.length} id=${messageId}${options.dryRun ? " (dry-run)" : ""}`);
185
202
  }
203
+ const missingAttachmentIds = [];
186
204
  if (attachmentIds.length) {
187
205
  const index = loadAttachmentIndex();
188
206
  for (let position = 0; position < attachmentIds.length; position += 1) {
@@ -190,21 +208,34 @@ function createOutboundTransport(options) {
190
208
  const attachment = index[attachmentId];
191
209
  if (!attachment) {
192
210
  options.log(`attachment 未找到 id=${attachmentId}(跳过)`);
211
+ missingAttachmentIds.push(attachmentId);
193
212
  continue;
194
213
  }
195
214
  const result = options.sendMedia(chatId, replyContext, attachment, `${idempotencyKey}:a${position}`);
196
215
  if (result.code !== 0) {
197
216
  options.log("attachment 发送失败 FULL:", `${result.stdout || ""} || ${result.stderr || ""}`);
198
- return { ok: false, status: 502, error: `lark-cli 发送附件失败: ${(result.stdout || result.stderr || "").trim().split("\n")[0]}` };
217
+ const error = `lark-cli 发送附件失败: ${(result.stdout || result.stderr || "").trim().split("\n")[0]}`;
218
+ report(false, { reason: error });
219
+ return { ok: false, status: 502, error };
199
220
  }
200
221
  try {
201
222
  const parsed = JSON.parse(result.stdout);
202
223
  messageId = parsed?.data?.message_id || messageId;
203
224
  }
204
225
  catch { /* retain prior message id */ }
226
+ committedProviderCalls += 1;
205
227
  options.log(`attach → chat=${chatId} name=${attachment.filename} id=${messageId}${options.dryRun ? " (dry-run)" : ""}`);
206
228
  }
207
229
  }
230
+ // Empty content plus attachment IDs that are all absent from the index
231
+ // reaches this point without any provider call; claiming success here would
232
+ // record delivery_succeeded for a reminder turn in which the user received
233
+ // nothing.
234
+ if (committedProviderCalls === 0) {
235
+ const error = `没有可发送的内容:附件不存在(${missingAttachmentIds.join(", ")}),且消息正文为空。`;
236
+ report(false, { reason: error });
237
+ return { ok: false, status: 400, error };
238
+ }
208
239
  options.appendConversation({
209
240
  direction: "out",
210
241
  from: options.botDisplayName() || options.agentName,
@@ -215,6 +246,9 @@ function createOutboundTransport(options) {
215
246
  messageId,
216
247
  at: new Date().toISOString(),
217
248
  });
249
+ // This is deliberately after every guarded provider call succeeds and after
250
+ // the local outbound projection, not at Runtime acceptance.
251
+ report(true, { messageId });
218
252
  return { ok: true, status: 200, data: { ok: true, state: "sent", messageId, messageSeq: 1 } };
219
253
  };
220
254
  return { handleSend, stageUpload };
@@ -149,16 +149,30 @@ export function createOutboundTransport(options) {
149
149
  };
150
150
  const handleSend = (body) => {
151
151
  const target = body.target || "";
152
+ const targetKey = String(target);
153
+ const report = (succeeded, details = {}) => {
154
+ if (options.dryRun)
155
+ return;
156
+ try {
157
+ options.onDeliveryOutcome?.({ target: targetKey, succeeded, ...details });
158
+ }
159
+ catch (error) {
160
+ options.log("delivery outcome hook 失败:", error.message);
161
+ }
162
+ };
152
163
  const content = String(body.content ?? "").replace(/\n+$/, "");
153
164
  const chatId = options.resolveChatId(target);
154
165
  if (!chatId) {
155
- return { ok: false, status: 400, error: `该 target 没有已知 chat_id:${JSON.stringify(target)}。只能回复 canonical Inbox 给出的确切会话;不会兜底发往默认群。` };
166
+ const error = `该 target 没有已知 chat_id:${JSON.stringify(target)}。只能回复 canonical Inbox 给出的确切会话;不会兜底发往默认群。`;
167
+ report(false, { reason: error });
168
+ return { ok: false, status: 400, error };
156
169
  }
157
170
  const idempotencyKey = body.idempotencyKey
158
171
  || crypto.createHash("sha256").update(`${String(target)}\0${content}`).digest("hex").slice(0, 32);
159
172
  const replyContext = options.replyContextFor(String(target)) || options.replyContextFor(chatId);
160
173
  const attachmentIds = Array.isArray(body.attachmentIds) ? body.attachmentIds.filter(Boolean) : [];
161
174
  let messageId = "sent";
175
+ let committedProviderCalls = 0;
162
176
  if (content !== "" || attachmentIds.length === 0) {
163
177
  const replyInThread = Boolean(replyContext?.in_topic && replyContext.reply_to);
164
178
  const result = replyInThread
@@ -166,7 +180,9 @@ export function createOutboundTransport(options) {
166
180
  : options.sendText(chatId, content, idempotencyKey);
167
181
  if (result.code !== 0) {
168
182
  options.log("send 失败 FULL:", `${result.stdout || ""} || ${result.stderr || ""}`);
169
- return { ok: false, status: 502, error: `lark-cli send failed: ${(result.stdout || result.stderr || "").trim().split("\n")[0]}` };
183
+ const error = `lark-cli send failed: ${(result.stdout || result.stderr || "").trim().split("\n")[0]}`;
184
+ report(false, { reason: error });
185
+ return { ok: false, status: 502, error };
170
186
  }
171
187
  try {
172
188
  const parsed = JSON.parse(result.stdout);
@@ -175,8 +191,10 @@ export function createOutboundTransport(options) {
175
191
  catch {
176
192
  messageId = options.dryRun ? "dryrun" : "sent";
177
193
  }
194
+ committedProviderCalls += 1;
178
195
  options.log(`${replyInThread ? "reply-in-thread" : "send"} → chat=${chatId} len=${content.length} id=${messageId}${options.dryRun ? " (dry-run)" : ""}`);
179
196
  }
197
+ const missingAttachmentIds = [];
180
198
  if (attachmentIds.length) {
181
199
  const index = loadAttachmentIndex();
182
200
  for (let position = 0; position < attachmentIds.length; position += 1) {
@@ -184,21 +202,34 @@ export function createOutboundTransport(options) {
184
202
  const attachment = index[attachmentId];
185
203
  if (!attachment) {
186
204
  options.log(`attachment 未找到 id=${attachmentId}(跳过)`);
205
+ missingAttachmentIds.push(attachmentId);
187
206
  continue;
188
207
  }
189
208
  const result = options.sendMedia(chatId, replyContext, attachment, `${idempotencyKey}:a${position}`);
190
209
  if (result.code !== 0) {
191
210
  options.log("attachment 发送失败 FULL:", `${result.stdout || ""} || ${result.stderr || ""}`);
192
- return { ok: false, status: 502, error: `lark-cli 发送附件失败: ${(result.stdout || result.stderr || "").trim().split("\n")[0]}` };
211
+ const error = `lark-cli 发送附件失败: ${(result.stdout || result.stderr || "").trim().split("\n")[0]}`;
212
+ report(false, { reason: error });
213
+ return { ok: false, status: 502, error };
193
214
  }
194
215
  try {
195
216
  const parsed = JSON.parse(result.stdout);
196
217
  messageId = parsed?.data?.message_id || messageId;
197
218
  }
198
219
  catch { /* retain prior message id */ }
220
+ committedProviderCalls += 1;
199
221
  options.log(`attach → chat=${chatId} name=${attachment.filename} id=${messageId}${options.dryRun ? " (dry-run)" : ""}`);
200
222
  }
201
223
  }
224
+ // Empty content plus attachment IDs that are all absent from the index
225
+ // reaches this point without any provider call; claiming success here would
226
+ // record delivery_succeeded for a reminder turn in which the user received
227
+ // nothing.
228
+ if (committedProviderCalls === 0) {
229
+ const error = `没有可发送的内容:附件不存在(${missingAttachmentIds.join(", ")}),且消息正文为空。`;
230
+ report(false, { reason: error });
231
+ return { ok: false, status: 400, error };
232
+ }
202
233
  options.appendConversation({
203
234
  direction: "out",
204
235
  from: options.botDisplayName() || options.agentName,
@@ -209,6 +240,9 @@ export function createOutboundTransport(options) {
209
240
  messageId,
210
241
  at: new Date().toISOString(),
211
242
  });
243
+ // This is deliberately after every guarded provider call succeeds and after
244
+ // the local outbound projection, not at Runtime acceptance.
245
+ report(true, { messageId });
212
246
  return { ok: true, status: 200, data: { ok: true, state: "sent", messageId, messageSeq: 1 } };
213
247
  };
214
248
  return { handleSend, stageUpload };
@@ -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 reminder_delivery_audit_js_1 = require("../agent/reminder-delivery-audit.cjs");
12
13
  const inbox_projection_js_1 = require("../agent/inbox-projection.cjs");
13
14
  const api_1 = require("@opentelemetry/api");
14
15
  const provider_error_classifier_js_1 = require("./provider-error-classifier.cjs");
@@ -1071,6 +1072,33 @@ function createRuntimeHost(options) {
1071
1072
  agent.piProactiveCompaction = tracked;
1072
1073
  return tracked;
1073
1074
  };
1075
+ // Reminder audit contexts are in-turn capabilities. Finalize any survivors
1076
+ // at both turn boundaries: turn end reconciles the turn that consumed them,
1077
+ // and turn start expires contexts left by a crashed/aborted prior turn so a
1078
+ // direct outbound cannot be falsely attributed to a stale reminder.
1079
+ const finalizeReminderContexts = (agent, reason) => {
1080
+ const legacyReminder = agent.stateStore?.resolveCurrentReminders ? null : agent.stateStore?.resolveCurrentReminder?.() ?? null;
1081
+ const reminderContexts = agent.stateStore?.resolveCurrentReminders?.() ?? (legacyReminder ? [legacyReminder] : []);
1082
+ let reminderAuditComplete = true;
1083
+ if (agent.stateStore?.paths?.reminders && agent.stateStore.clearCurrentReminder) {
1084
+ for (const reminder of reminderContexts) {
1085
+ try {
1086
+ (0, reminder_delivery_audit_js_1.auditReminderDelivery)({ stateStore: agent.stateStore, agentId: agent.config.agentId,
1087
+ reminderId: reminder.reminderId, deliveryAnchor: reminder.deliveryAnchor, target: reminder.deliveryTarget,
1088
+ succeeded: reminder.deliveryCommitted === true, finalize: true,
1089
+ ...(reminder.deliveryMessageId ? { messageId: reminder.deliveryMessageId } : {}),
1090
+ ...(!reminder.deliveryCommitted ? { reason } : {}) });
1091
+ }
1092
+ catch {
1093
+ // Retain the context if the failure could not be durably recorded;
1094
+ // a later matching outbound or turn can reconcile it.
1095
+ reminderAuditComplete = false;
1096
+ }
1097
+ }
1098
+ }
1099
+ if (reminderAuditComplete)
1100
+ agent.stateStore?.clearCurrentReminders?.();
1101
+ };
1074
1102
  const observe = (agent, session, event) => {
1075
1103
  if (agent.session !== session)
1076
1104
  return; // Ignore late output from a replaced child.
@@ -1113,6 +1141,10 @@ function createRuntimeHost(options) {
1113
1141
  ...(event.model ? { model: event.model } : {}), ...(event.reasoningEffort ? { reasoningEffort: event.reasoningEffort } : {}) });
1114
1142
  }
1115
1143
  else if (event.type === "turn-start") {
1144
+ // Clear a source left by a crashed/aborted prior turn before a new turn
1145
+ // can execute a direct task without an Inbox poll.
1146
+ agent.stateStore?.clearCurrentInboxSource?.();
1147
+ finalizeReminderContexts(agent, "Runtime turn started before a prior turn reconciled this reminder delivery");
1116
1148
  agent.busy = true;
1117
1149
  agent.turnInProgress = true;
1118
1150
  agent.turnHadFailure = false;
@@ -1134,6 +1166,11 @@ function createRuntimeHost(options) {
1134
1166
  agent.busy = false;
1135
1167
  emit({ type: "activity", agentId: agent.config.agentId, activity: "idle", activityKind: "idle", detailKind: "turn_ended" });
1136
1168
  reconcileAcceptedAtTurnEnd(agent);
1169
+ // last_source and reminder audit contexts are in-turn capabilities,
1170
+ // never cross-turn defaults. A later direct Runtime task must fail
1171
+ // closed instead of inheriting a previous turn's chat or reminder.
1172
+ agent.stateStore?.clearCurrentInboxSource?.();
1173
+ finalizeReminderContexts(agent, "Runtime turn ended without a matching Feishu write");
1137
1174
  if (agent.backgroundCompletionInFlight) {
1138
1175
  if (agent.turnHadFailure)
1139
1176
  failBackgroundCompletionWake(agent);
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { agentCliPromptCapabilities } from "../agent/agent-cli-capabilities.mjs";
5
+ import { auditReminderDelivery } from "../agent/reminder-delivery-audit.mjs";
5
6
  import { isCanonicalInboxTarget, targetKeyOfInboxEnvelope } from "../agent/inbox-projection.mjs";
6
7
  import { SpanKind } from "@opentelemetry/api";
7
8
  import { buildStrictProviderErrorInput, classifyStrictProviderError } from "./provider-error-classifier.mjs";
@@ -1062,6 +1063,33 @@ export function createRuntimeHost(options) {
1062
1063
  agent.piProactiveCompaction = tracked;
1063
1064
  return tracked;
1064
1065
  };
1066
+ // Reminder audit contexts are in-turn capabilities. Finalize any survivors
1067
+ // at both turn boundaries: turn end reconciles the turn that consumed them,
1068
+ // and turn start expires contexts left by a crashed/aborted prior turn so a
1069
+ // direct outbound cannot be falsely attributed to a stale reminder.
1070
+ const finalizeReminderContexts = (agent, reason) => {
1071
+ const legacyReminder = agent.stateStore?.resolveCurrentReminders ? null : agent.stateStore?.resolveCurrentReminder?.() ?? null;
1072
+ const reminderContexts = agent.stateStore?.resolveCurrentReminders?.() ?? (legacyReminder ? [legacyReminder] : []);
1073
+ let reminderAuditComplete = true;
1074
+ if (agent.stateStore?.paths?.reminders && agent.stateStore.clearCurrentReminder) {
1075
+ for (const reminder of reminderContexts) {
1076
+ try {
1077
+ auditReminderDelivery({ stateStore: agent.stateStore, agentId: agent.config.agentId,
1078
+ reminderId: reminder.reminderId, deliveryAnchor: reminder.deliveryAnchor, target: reminder.deliveryTarget,
1079
+ succeeded: reminder.deliveryCommitted === true, finalize: true,
1080
+ ...(reminder.deliveryMessageId ? { messageId: reminder.deliveryMessageId } : {}),
1081
+ ...(!reminder.deliveryCommitted ? { reason } : {}) });
1082
+ }
1083
+ catch {
1084
+ // Retain the context if the failure could not be durably recorded;
1085
+ // a later matching outbound or turn can reconcile it.
1086
+ reminderAuditComplete = false;
1087
+ }
1088
+ }
1089
+ }
1090
+ if (reminderAuditComplete)
1091
+ agent.stateStore?.clearCurrentReminders?.();
1092
+ };
1065
1093
  const observe = (agent, session, event) => {
1066
1094
  if (agent.session !== session)
1067
1095
  return; // Ignore late output from a replaced child.
@@ -1104,6 +1132,10 @@ export function createRuntimeHost(options) {
1104
1132
  ...(event.model ? { model: event.model } : {}), ...(event.reasoningEffort ? { reasoningEffort: event.reasoningEffort } : {}) });
1105
1133
  }
1106
1134
  else if (event.type === "turn-start") {
1135
+ // Clear a source left by a crashed/aborted prior turn before a new turn
1136
+ // can execute a direct task without an Inbox poll.
1137
+ agent.stateStore?.clearCurrentInboxSource?.();
1138
+ finalizeReminderContexts(agent, "Runtime turn started before a prior turn reconciled this reminder delivery");
1107
1139
  agent.busy = true;
1108
1140
  agent.turnInProgress = true;
1109
1141
  agent.turnHadFailure = false;
@@ -1125,6 +1157,11 @@ export function createRuntimeHost(options) {
1125
1157
  agent.busy = false;
1126
1158
  emit({ type: "activity", agentId: agent.config.agentId, activity: "idle", activityKind: "idle", detailKind: "turn_ended" });
1127
1159
  reconcileAcceptedAtTurnEnd(agent);
1160
+ // last_source and reminder audit contexts are in-turn capabilities,
1161
+ // never cross-turn defaults. A later direct Runtime task must fail
1162
+ // closed instead of inheriting a previous turn's chat or reminder.
1163
+ agent.stateStore?.clearCurrentInboxSource?.();
1164
+ finalizeReminderContexts(agent, "Runtime turn ended without a matching Feishu write");
1128
1165
  if (agent.backgroundCompletionInFlight) {
1129
1166
  if (agent.turnHadFailure)
1130
1167
  failBackgroundCompletionWake(agent);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkin",
3
- "version": "0.4.13",
3
+ "version": "0.4.14",
4
4
  "private": false,
5
5
  "larkinPackageRole": "npm-published",
6
6
  "files": [