larkin 0.4.13 → 0.4.15
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/dist/agent/agent-state-store.cjs +237 -0
- package/dist/agent/agent-state-store.mjs +238 -1
- package/dist/agent/context-prompt.cjs +3 -1
- package/dist/agent/context-prompt.mjs +3 -1
- package/dist/agent/host-reminder-orchestrator.cjs +134 -7
- package/dist/agent/host-reminder-orchestrator.mjs +134 -7
- package/dist/agent/inbox-projection.cjs +5 -0
- package/dist/agent/inbox-projection.mjs +4 -0
- package/dist/agent/reminder-delivery-audit.cjs +86 -0
- package/dist/agent/reminder-delivery-audit.mjs +50 -0
- package/dist/agent/reminder-routes.cjs +83 -3
- package/dist/agent/reminder-routes.mjs +83 -3
- package/dist/agent/reminder-store.cjs +5 -2
- package/dist/agent/reminder-store.mjs +5 -2
- package/dist/agent/transport-business-context.cjs +26 -2
- package/dist/agent/transport-business-context.mjs +26 -2
- package/dist/app/agent-cli.mjs +33 -2
- package/dist/app/lark-cli.mjs +79 -11
- package/dist/feishu/host-business-state.cjs +35 -14
- package/dist/feishu/host-business-state.mjs +35 -14
- package/dist/feishu/interaction-orchestrator.cjs +8 -1
- package/dist/feishu/interaction-orchestrator.mjs +8 -1
- package/dist/feishu/outbound-transport.cjs +37 -3
- package/dist/feishu/outbound-transport.mjs +37 -3
- package/dist/runtime/pi-inline-extensions.mjs +31 -2
- package/dist/runtime/pi-subagent-injection.mjs +50 -13
- package/dist/runtime/pi-subagents.bundle.js +57 -10
- package/dist/runtime/runtime-host.cjs +37 -0
- package/dist/runtime/runtime-host.mjs +37 -0
- package/package.json +5 -1
- package/patches/@tintinweb%2Fpi-subagents@0.14.3.patch +249 -0
|
@@ -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
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
:
|
|
321
|
-
?
|
|
322
|
-
:
|
|
323
|
-
?
|
|
324
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
|
@@ -1,8 +1,37 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
2
|
import bashTimeoutExtension from "./pi-bash-timeout-extension.mjs";
|
|
3
|
+
import { bundledPiSubagentExtensionPath } from "./pi-subagent-injection.mjs";
|
|
4
|
+
/**
|
|
5
|
+
* Load the prebuilt, patched subagent bundle shipped in dist/ rather than the
|
|
6
|
+
* package-manager copy. Standalone binaries additionally load the patched
|
|
7
|
+
* package through Bun's compiled module graph, while normal package installs
|
|
8
|
+
* continue to use the prebuilt patched bundle.
|
|
9
|
+
*/
|
|
10
|
+
async function loadBundledPiSubagentExtension() {
|
|
11
|
+
if (process.env.LARKIN_STANDALONE === "1") {
|
|
12
|
+
const loaded = await import("@tintinweb/pi-subagents/dist/index.js");
|
|
13
|
+
if (!loaded.default)
|
|
14
|
+
throw new Error("Larkin bounded pi-subagents package is invalid; refusing to start builtin Pi");
|
|
15
|
+
return loaded.default;
|
|
16
|
+
}
|
|
17
|
+
const bundle = bundledPiSubagentExtensionPath(process.env.LARKIN_CONFIG_DIR);
|
|
18
|
+
if (!bundle)
|
|
19
|
+
throw new Error("Larkin bounded pi-subagents bundle is unavailable; refusing to start builtin Pi");
|
|
20
|
+
const loaded = await import(pathToFileURL(bundle).href);
|
|
21
|
+
if (!loaded.default)
|
|
22
|
+
throw new Error("Larkin bounded pi-subagents bundle is invalid; refusing to start builtin Pi");
|
|
23
|
+
return loaded.default;
|
|
24
|
+
}
|
|
3
25
|
/** Builtin Pi extensions are loaded as code, never through Pi's path/data-URL loader. */
|
|
26
|
+
const bundledSubagentsExtension = async (pi) => {
|
|
27
|
+
const extension = await loadBundledPiSubagentExtension();
|
|
28
|
+
if (typeof extension === "function")
|
|
29
|
+
await extension(pi);
|
|
30
|
+
else
|
|
31
|
+
await extension.factory(pi);
|
|
32
|
+
};
|
|
4
33
|
export const BUILTIN_PI_EXTENSION_FACTORIES = Object.freeze([
|
|
5
|
-
|
|
34
|
+
bundledSubagentsExtension,
|
|
6
35
|
bashTimeoutExtension,
|
|
7
36
|
]);
|
|
8
37
|
/** Invoke Pi RPC with Larkin's builtin-only extension factories. */
|
|
@@ -81,39 +81,76 @@ export function probeExternalPiVersion(piCommand, env) {
|
|
|
81
81
|
* 已装时 Larkin 不再 -e 注入,避免同名工具(Agent/get_subagent_result/steer_subagent)
|
|
82
82
|
* 重复注册导致 pi 扩展加载 FATAL(pi 对 duplicate tool registration 是硬失败)。
|
|
83
83
|
*/
|
|
84
|
-
|
|
84
|
+
const BOUNDED_WAIT_CAPABILITY = "larkin-pi-subagents-bounded-wait-v1";
|
|
85
|
+
function userPiSubagentsWaitCapability(env) {
|
|
85
86
|
const agentDir = env.PI_CODING_AGENT_DIR || path.join(env.HOME || process.env.HOME || "", ".pi", "agent");
|
|
86
87
|
try {
|
|
88
|
+
let configured = false;
|
|
87
89
|
const settingsFile = path.join(agentDir, "settings.json");
|
|
88
90
|
if (fs.existsSync(settingsFile)) {
|
|
89
91
|
const settings = JSON.parse(fs.readFileSync(settingsFile, "utf8"));
|
|
90
92
|
const packages = settings?.packages;
|
|
91
93
|
if (Array.isArray(packages)) {
|
|
92
|
-
|
|
93
|
-
if (typeof entry === "string" && /pi-subagents/i.test(entry))
|
|
94
|
-
return true;
|
|
95
|
-
}
|
|
94
|
+
configured = packages.some((entry) => typeof entry === "string" && /pi-subagents/i.test(entry));
|
|
96
95
|
}
|
|
97
96
|
}
|
|
98
97
|
// settings.json 可能未登记但包目录已存在(残留/手动安装)。
|
|
99
98
|
// Split token: repo contract forbids the literal package-manager word in sources.
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
99
|
+
const packageRoots = [
|
|
100
|
+
path.join(agentDir, "n" + "pm", "node_modules", "@tintinweb", "pi-subagents"),
|
|
101
|
+
path.join(agentDir, "node_modules", "@tintinweb", "pi-subagents"),
|
|
102
|
+
];
|
|
103
|
+
const installedRoot = packageRoots.find((root) => fs.existsSync(root));
|
|
104
|
+
if (!configured && !installedRoot)
|
|
105
|
+
return "absent";
|
|
106
|
+
if (!installedRoot)
|
|
107
|
+
return "unbounded";
|
|
108
|
+
const packageManifest = JSON.parse(fs.readFileSync(path.join(installedRoot, "package.json"), "utf8"));
|
|
109
|
+
const extensions = packageManifest.pi?.extensions;
|
|
110
|
+
if (!Array.isArray(extensions) || extensions.length === 0
|
|
111
|
+
|| !extensions.every((entry) => typeof entry === "string" && entry.length > 0)) {
|
|
112
|
+
return "unbounded";
|
|
113
|
+
}
|
|
114
|
+
// Pi executes the entries declared by the package manifest. Do not trust a
|
|
115
|
+
// marker in a build artifact when the manifest points Pi at another file.
|
|
116
|
+
const capabilityFiles = extensions.map((entry) => path.resolve(installedRoot, entry));
|
|
117
|
+
const hasCapability = capabilityFiles.every((file) => {
|
|
118
|
+
try {
|
|
119
|
+
return fs.readFileSync(file, "utf8").includes(BOUNDED_WAIT_CAPABILITY);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
return hasCapability ? "bounded" : "unbounded";
|
|
103
126
|
}
|
|
104
127
|
catch {
|
|
105
|
-
|
|
128
|
+
// Unreadable user extension state is not safe to treat as bounded.
|
|
129
|
+
return "unbounded";
|
|
106
130
|
}
|
|
107
|
-
|
|
131
|
+
}
|
|
132
|
+
export function userPiAlreadyHasSubagentsExtension(env) {
|
|
133
|
+
return userPiSubagentsWaitCapability(env) !== "absent";
|
|
134
|
+
}
|
|
135
|
+
export function userPiSubagentsHasBoundedWaitCapability(env) {
|
|
136
|
+
return userPiSubagentsWaitCapability(env) === "bounded";
|
|
108
137
|
}
|
|
109
138
|
export function resolvePiSubagentExtensionArg(input, probeVersion = () => probeExternalPiVersion(input.piCommand, input.env), resolveBundle = () => bundledPiSubagentExtensionPath(input.env.LARKIN_CONFIG_DIR)) {
|
|
139
|
+
// A user-installed extension wins only when it advertises the exact bounded
|
|
140
|
+
// wait capability. Check this before the bundle path so a missing Larkin
|
|
141
|
+
// asset cannot silently leave an unbounded user extension active.
|
|
142
|
+
if (userPiAlreadyHasSubagentsExtension(input.env)) {
|
|
143
|
+
if (!userPiSubagentsHasBoundedWaitCapability(input.env)) {
|
|
144
|
+
throw new Error("[larkin] WARNING: refusing external Pi because the user-installed " +
|
|
145
|
+
"pi-subagents extension is unbounded or unverifiable; remove it or install " +
|
|
146
|
+
"a version advertising larkin-pi-subagents-bounded-wait-v1.");
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
110
150
|
// resolveBundle is injectable so unit tests stay environment-independent
|
|
111
151
|
// (no dependency on build artifacts or the filesystem).
|
|
112
152
|
const bundle = resolveBundle();
|
|
113
153
|
if (!bundle)
|
|
114
154
|
return null;
|
|
115
|
-
// 用户已自行安装同款扩展时跳过注入,避免工具名重复注册冲突。
|
|
116
|
-
if (userPiAlreadyHasSubagentsExtension(input.env))
|
|
117
|
-
return null;
|
|
118
155
|
return piVersionSupportsSubagents(probeVersion()) ? bundle : null;
|
|
119
156
|
}
|
|
@@ -7104,6 +7104,43 @@ function abortable(promise, signal) {
|
|
|
7104
7104
|
});
|
|
7105
7105
|
});
|
|
7106
7106
|
}
|
|
7107
|
+
var RESULT_WAIT_QUEUE_POLL_MS = 50;
|
|
7108
|
+
var DEFAULT_RESULT_WAIT_TIMEOUT_MS = 30000;
|
|
7109
|
+
var MAX_RESULT_WAIT_TIMEOUT_MS = 60000;
|
|
7110
|
+
function normalizeResultWaitTimeout(timeoutMs) {
|
|
7111
|
+
if (timeoutMs === undefined || !Number.isFinite(timeoutMs))
|
|
7112
|
+
return DEFAULT_RESULT_WAIT_TIMEOUT_MS;
|
|
7113
|
+
return Math.max(1, Math.min(MAX_RESULT_WAIT_TIMEOUT_MS, Math.floor(timeoutMs)));
|
|
7114
|
+
}
|
|
7115
|
+
function waitWithDeadline(promise, timeoutMs, signal) {
|
|
7116
|
+
let timer;
|
|
7117
|
+
const timeout = new Promise((resolve2) => {
|
|
7118
|
+
timer = setTimeout(() => resolve2({ timedOut: true }), timeoutMs);
|
|
7119
|
+
});
|
|
7120
|
+
return abortable(Promise.race([
|
|
7121
|
+
promise.then((value) => ({ timedOut: false, value })),
|
|
7122
|
+
timeout
|
|
7123
|
+
]), signal).finally(() => {
|
|
7124
|
+
if (timer !== undefined)
|
|
7125
|
+
clearTimeout(timer);
|
|
7126
|
+
});
|
|
7127
|
+
}
|
|
7128
|
+
async function waitForSubagentResult(record, timeoutMs, signal) {
|
|
7129
|
+
const deadline = Date.now() + timeoutMs;
|
|
7130
|
+
while (record.status === "queued") {
|
|
7131
|
+
const remainingMs2 = deadline - Date.now();
|
|
7132
|
+
if (remainingMs2 <= 0)
|
|
7133
|
+
return true;
|
|
7134
|
+
await abortable(new Promise((resolve2) => setTimeout(resolve2, Math.min(RESULT_WAIT_QUEUE_POLL_MS, remainingMs2))), signal);
|
|
7135
|
+
}
|
|
7136
|
+
if (!record.promise)
|
|
7137
|
+
return false;
|
|
7138
|
+
const remainingMs = deadline - Date.now();
|
|
7139
|
+
if (remainingMs <= 0)
|
|
7140
|
+
return true;
|
|
7141
|
+
return (await waitWithDeadline(record.promise, remainingMs, signal)).timedOut;
|
|
7142
|
+
}
|
|
7143
|
+
var LARKIN_BOUNDED_WAIT_CAPABILITY = "larkin-pi-subagents-bounded-wait-v1";
|
|
7107
7144
|
function renderRunningAgentStatus(frame, statsText, activity, theme) {
|
|
7108
7145
|
const container = new Container;
|
|
7109
7146
|
container.addChild(new Text(theme.fg("accent", frame) + (statsText ? " " + statsText : ""), 0, 0));
|
|
@@ -7287,7 +7324,10 @@ function src_default(pi) {
|
|
|
7287
7324
|
const agentActivity = new Map;
|
|
7288
7325
|
const pendingNudges = new Map;
|
|
7289
7326
|
const NUDGE_HOLD_MS = 200;
|
|
7290
|
-
|
|
7327
|
+
let resultWaitConsumedInParentTurn = false;
|
|
7328
|
+
pi.on("agent_start", () => {
|
|
7329
|
+
resultWaitConsumedInParentTurn = false;
|
|
7330
|
+
});
|
|
7291
7331
|
function scheduleNudge(key, send, delay = NUDGE_HOLD_MS) {
|
|
7292
7332
|
cancelNudge(key);
|
|
7293
7333
|
pendingNudges.set(key, setTimeout(() => {
|
|
@@ -8147,6 +8187,9 @@ You will be notified when this agent completes.
|
|
|
8147
8187
|
wait: Type.Optional(Type.Boolean({
|
|
8148
8188
|
description: "If true, wait for the agent to complete before returning. Default: false."
|
|
8149
8189
|
})),
|
|
8190
|
+
timeout_ms: Type.Optional(Type.Number({
|
|
8191
|
+
description: "Maximum wait in milliseconds when wait is true. Defaults to 30000 and is capped at 60000."
|
|
8192
|
+
})),
|
|
8150
8193
|
verbose: Type.Optional(Type.Boolean({
|
|
8151
8194
|
description: "If true, include the agent's full conversation (messages + tool calls). Default: false."
|
|
8152
8195
|
}))
|
|
@@ -8156,12 +8199,14 @@ You will be notified when this agent completes.
|
|
|
8156
8199
|
if (!record) {
|
|
8157
8200
|
return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
|
|
8158
8201
|
}
|
|
8202
|
+
if (params.wait && resultWaitConsumedInParentTurn) {
|
|
8203
|
+
throw new Error("Refusing wait: true after an earlier wait began in this parent turn; yield and await the completion notification instead.");
|
|
8204
|
+
}
|
|
8205
|
+
let timedOut = false;
|
|
8206
|
+
const waitTimeoutMs = normalizeResultWaitTimeout(params.timeout_ms);
|
|
8159
8207
|
if (params.wait && (record.status === "running" || record.status === "queued")) {
|
|
8160
|
-
|
|
8161
|
-
|
|
8162
|
-
}
|
|
8163
|
-
if (record.promise)
|
|
8164
|
-
await abortable(record.promise, signal);
|
|
8208
|
+
resultWaitConsumedInParentTurn = true;
|
|
8209
|
+
timedOut = await waitForSubagentResult(record, waitTimeoutMs, signal);
|
|
8165
8210
|
}
|
|
8166
8211
|
const displayName = getDisplayName(record.type);
|
|
8167
8212
|
const duration = formatDuration(record.startedAt, record.completedAt);
|
|
@@ -8176,12 +8221,12 @@ You will be notified when this agent completes.
|
|
|
8176
8221
|
statsParts.push(`Compactions: ${record.compactionCount}`);
|
|
8177
8222
|
statsParts.push(`Duration: ${duration}`);
|
|
8178
8223
|
let output = `Agent: ${record.id}
|
|
8179
|
-
` + `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}
|
|
8224
|
+
` + `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)}${timedOut ? " | timedOut: true" : ""} | ${statsParts.join(" | ")}
|
|
8180
8225
|
` + `Description: ${record.description}
|
|
8181
8226
|
|
|
8182
8227
|
`;
|
|
8183
|
-
if (record.status === "running") {
|
|
8184
|
-
output += "Agent is still running. Use wait: true or check back later.";
|
|
8228
|
+
if (record.status === "running" || record.status === "queued") {
|
|
8229
|
+
output += timedOut ? `Agent is still ${record.status}. Wait timed out after ${waitTimeoutMs}ms; the background agent was not cancelled. Yield/end this turn and await the completion notification; do not call wait: true again.` : "Agent is still running. Use wait: true or check back later.";
|
|
8185
8230
|
} else if (record.status === "error") {
|
|
8186
8231
|
output += `Error: ${record.error}${partialOutputSuffix(record)}`;
|
|
8187
8232
|
} else {
|
|
@@ -8985,6 +9030,8 @@ ${systemPrompt}
|
|
|
8985
9030
|
});
|
|
8986
9031
|
}
|
|
8987
9032
|
export {
|
|
9033
|
+
waitForSubagentResult,
|
|
8988
9034
|
renderRunningAgentStatus,
|
|
8989
|
-
src_default as default
|
|
9035
|
+
src_default as default,
|
|
9036
|
+
LARKIN_BOUNDED_WAIT_CAPABILITY
|
|
8990
9037
|
};
|
|
@@ -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);
|