@ouro.bot/cli 0.1.0-alpha.932 → 0.1.0-alpha.934
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/changelog.json +19 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/mind/session-transaction.js +73 -15
- package/dist/senses/shepherd/judge.js +8 -6
- package/dist/senses/shepherd/owner-notice.js +85 -31
- package/dist/senses/shepherd/sense.js +103 -26
- package/dist/senses/teams.js +249 -118
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.934",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Hold duplicate Teams activities durably before model execution, steering or quoted queuing, preserving receipts across restart and session reset.",
|
|
8
|
+
"Do not replay uncertain Teams sends after a lost acknowledgement; split-message recovery requires an explicit HTTP 413 and smaller payloads.",
|
|
9
|
+
"Preserve unconsumed Teams steering in its original request context, reject incomplete production identities, and bypass the SDK stream hidden retry loop with acknowledged single-attempt messages.",
|
|
10
|
+
"Keep distinct Teams fallback actors separate when optional AAD metadata is empty.",
|
|
11
|
+
"Include bot and request correlation context on Teams handler and SDK error telemetry."
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.1.0-alpha.933",
|
|
16
|
+
"changes": [
|
|
17
|
+
"Recover existing supervised Teams tunnels after permanent rejection or sustained readiness loss, with scoped and confirmed bot endpoint registration.",
|
|
18
|
+
"Recover accepted Shepherd notice intent without importing audit history, and compact pending observations into bounded historical session digests with durable receipt custody.",
|
|
19
|
+
"Preserve valid premature judgments independently of invalid suggested replies; keep length-only periodic fixed-text continuation behind all existing input safety guards.",
|
|
20
|
+
"Correlate Teams request and reply stages without message bodies, and contain notice-ledger failures so later returns remain runnable."
|
|
21
|
+
]
|
|
22
|
+
},
|
|
4
23
|
{
|
|
5
24
|
"version": "0.1.0-alpha.932",
|
|
6
25
|
"changes": [
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0"?>
|
|
2
2
|
<Container version="2">
|
|
3
3
|
<Name>ouro-butler</Name>
|
|
4
|
-
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.934</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
|
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.SessionTransactionError = exports.SessionTurnBusyError = void 0;
|
|
40
|
+
exports.admitSessionActivity = admitSessionActivity;
|
|
40
41
|
exports.acquireSessionTurnLease = acquireSessionTurnLease;
|
|
41
42
|
exports.withSessionTurnLease = withSessionTurnLease;
|
|
42
43
|
exports.withImmediateSessionTurnLease = withImmediateSessionTurnLease;
|
|
@@ -176,21 +177,78 @@ function checkTemporary(pin, filePath, identity) {
|
|
|
176
177
|
function openLeaseDatabase(lockPath, busyTimeoutMs = 0, confinement = null) {
|
|
177
178
|
checkConfinement(confinement);
|
|
178
179
|
const database = new better_sqlite3_1.default(lockPath);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
180
|
+
try {
|
|
181
|
+
database.pragma(`busy_timeout = ${busyTimeoutMs}`);
|
|
182
|
+
database.transaction(() => {
|
|
183
|
+
database.exec(`
|
|
184
|
+
CREATE TABLE IF NOT EXISTS session_turn_lease (
|
|
185
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
186
|
+
pid INTEGER NOT NULL,
|
|
187
|
+
owner_id TEXT NOT NULL,
|
|
188
|
+
owner_token TEXT NOT NULL
|
|
189
|
+
)
|
|
190
|
+
`);
|
|
191
|
+
const columns = new Set(database.prepare(`PRAGMA table_info(session_turn_lease)`).all().map((column) => column.name));
|
|
192
|
+
if (!columns.has("boot_identity"))
|
|
193
|
+
database.exec(`ALTER TABLE session_turn_lease ADD COLUMN boot_identity TEXT`);
|
|
194
|
+
if (!columns.has("process_started_at"))
|
|
195
|
+
database.exec(`ALTER TABLE session_turn_lease ADD COLUMN process_started_at TEXT`);
|
|
196
|
+
}).immediate();
|
|
197
|
+
return database;
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
database.close();
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** Immutable admission, not proof of execution or delivery. Unknown effects must never be replayed. */
|
|
205
|
+
function admitSessionActivity(sessionPath, input) {
|
|
206
|
+
if (typeof input.namespace !== "string" || !input.namespace.trim() || Buffer.byteLength(input.namespace) > 4096
|
|
207
|
+
|| typeof input.activityId !== "string" || !input.activityId.trim() || Buffer.byteLength(input.activityId) > 1024
|
|
208
|
+
|| typeof input.payloadDigest !== "string" || !/^[a-f0-9]{64}$/.test(input.payloadDigest)) {
|
|
209
|
+
throw new SessionTransactionError("invalid session activity admission");
|
|
210
|
+
}
|
|
211
|
+
const canonical = canonicalSessionPath(sessionPath);
|
|
212
|
+
const lockPath = `${canonical}.turn.lock`;
|
|
213
|
+
fs.mkdirSync(path.dirname(canonical), { recursive: true, mode: 0o700 });
|
|
214
|
+
const receiptId = revision(JSON.stringify([input.namespace, input.activityId]));
|
|
215
|
+
const database = openLeaseDatabase(lockPath, 1000);
|
|
216
|
+
try {
|
|
217
|
+
database.pragma("synchronous = FULL");
|
|
218
|
+
const state = database.transaction(() => {
|
|
219
|
+
database.exec(`
|
|
220
|
+
CREATE TABLE IF NOT EXISTS session_activity_receipts (
|
|
221
|
+
receipt_id TEXT PRIMARY KEY,
|
|
222
|
+
payload_digest TEXT NOT NULL,
|
|
223
|
+
admitted_at TEXT NOT NULL
|
|
224
|
+
)
|
|
225
|
+
`);
|
|
226
|
+
const existing = database.prepare("SELECT payload_digest FROM session_activity_receipts WHERE receipt_id = ?")
|
|
227
|
+
.get(receiptId);
|
|
228
|
+
if (existing) {
|
|
229
|
+
if (typeof existing.payload_digest !== "string" || !/^[a-f0-9]{64}$/.test(existing.payload_digest)) {
|
|
230
|
+
throw new SessionTransactionError("invalid session activity receipt");
|
|
231
|
+
}
|
|
232
|
+
if (existing.payload_digest !== input.payloadDigest)
|
|
233
|
+
throw new SessionTransactionError("conflicting session activity payload");
|
|
234
|
+
return "duplicate";
|
|
235
|
+
}
|
|
236
|
+
// Never evict an admission: losing its identity would authorize uncertain work again.
|
|
237
|
+
if (fs.statSync(lockPath).size >= 64 * 1024 * 1024)
|
|
238
|
+
throw new SessionTransactionError("session activity receipt capacity exceeded");
|
|
239
|
+
database.prepare("INSERT INTO session_activity_receipts (receipt_id, payload_digest, admitted_at) VALUES (?, ?, ?)")
|
|
240
|
+
.run(receiptId, input.payloadDigest, new Date().toISOString());
|
|
241
|
+
return "admitted";
|
|
242
|
+
}).immediate();
|
|
243
|
+
(0, runtime_1.emitNervesEvent)({
|
|
244
|
+
component: "mind", event: "mind.session_activity_admission", message: "recorded session activity admission decision",
|
|
245
|
+
meta: { receiptId, state },
|
|
246
|
+
});
|
|
247
|
+
return { state, receiptId };
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
database.close();
|
|
251
|
+
}
|
|
194
252
|
}
|
|
195
253
|
function acquisitionRecord(options) {
|
|
196
254
|
const pid = options.pid ?? process.pid;
|
|
@@ -25,11 +25,11 @@ const CALLBACKS = { onModelStart: NOOP, onModelStreamStart: NOOP, onTextChunk: N
|
|
|
25
25
|
function replyTextProblem(text) {
|
|
26
26
|
const line = text.replace(/`/g, "").replace(/\s+/g, " ").trim();
|
|
27
27
|
if (!line)
|
|
28
|
-
return { problem: "an empty reply" };
|
|
29
|
-
if (line.length > exports.REPLY_MAX_CHARS)
|
|
30
|
-
return { problem: `a reply of ${line.length} characters (the limit is ${exports.REPLY_MAX_CHARS})` };
|
|
28
|
+
return { problem: "an empty reply", issue: "missing" };
|
|
31
29
|
if (/[\\@;$|<>&\p{Cc}\p{Cf}]/u.test(line) || /^[/!#]/.test(line))
|
|
32
|
-
return { problem: "a reply with characters a terminal could read as a command" };
|
|
30
|
+
return { problem: "a reply with characters a terminal could read as a command", issue: "unsafe" };
|
|
31
|
+
if (line.length > exports.REPLY_MAX_CHARS)
|
|
32
|
+
return { problem: `a reply of ${line.length} characters (the limit is ${exports.REPLY_MAX_CHARS})`, issue: "too_long" };
|
|
33
33
|
return { line };
|
|
34
34
|
}
|
|
35
35
|
function safeReplyText(text) {
|
|
@@ -81,8 +81,10 @@ function parseVerdict(content) {
|
|
|
81
81
|
return { kind: "unclear", reply: null, reason: `premature, but the key came from JSON inside prose: ${reason}` };
|
|
82
82
|
return { kind, reply: { key: reply.key }, reason };
|
|
83
83
|
}
|
|
84
|
-
const checked =
|
|
85
|
-
|
|
84
|
+
const checked = Object.hasOwn(reply, "key") ? { problem: "an unsupported key", issue: "unsafe" }
|
|
85
|
+
: typeof reply.text === "string" ? replyTextProblem(reply.text) : { problem: "no reply", issue: "missing" };
|
|
86
|
+
return "line" in checked ? { kind, reply: { text: checked.line }, reason }
|
|
87
|
+
: { kind: parsed.inProse ? "unclear" : kind, reply: null, replyIssue: checked.issue, reason: `premature, but the judge gave ${checked.problem}: ${reason}` };
|
|
86
88
|
}
|
|
87
89
|
function judgeMessages(packet) {
|
|
88
90
|
return [
|
|
@@ -47,7 +47,6 @@ const friends_1 = require("@ouro.bot/friends");
|
|
|
47
47
|
const await_alert_1 = require("../../heart/awaiting/await-alert");
|
|
48
48
|
const config_1 = require("../../heart/config");
|
|
49
49
|
const runtime_1 = require("../../nerves/runtime");
|
|
50
|
-
const returns_1 = require("./returns");
|
|
51
50
|
const notice_outbox_1 = require("./notice-outbox");
|
|
52
51
|
/**
|
|
53
52
|
* The owner notice: after a judged return, the agent's owner hears what happened in one short plain message on their
|
|
@@ -55,12 +54,23 @@ const notice_outbox_1 = require("./notice-outbox");
|
|
|
55
54
|
* policy covers whether or not the Ouro agent decides it matters. Returns that arrive close together share one message.
|
|
56
55
|
*/
|
|
57
56
|
exports.NOTICE_COALESCE_MS = 5_000;
|
|
58
|
-
/**
|
|
57
|
+
/** Distinct sessions named in a digest; the remaining sessions are counted. */
|
|
59
58
|
exports.NOTICE_LIST_MAX = 6;
|
|
60
59
|
exports.NOTICE_RETRY_MS = 30_000;
|
|
61
60
|
const NAME_MAX = 60;
|
|
62
61
|
const REASON_MAX = 300;
|
|
63
62
|
const REPLY_MAX = 240;
|
|
63
|
+
const BLOCK_DESCRIPTION = {
|
|
64
|
+
reply_invalid: "the suggested reply was invalid",
|
|
65
|
+
menu_disallowed: "scheduled continuation cannot approve a menu",
|
|
66
|
+
host_unverified: "the host could not verify safe input state",
|
|
67
|
+
guidance: "operator guidance blocked input",
|
|
68
|
+
loop_guard: "the reply limit was reached",
|
|
69
|
+
focus: "the human owns the focused session",
|
|
70
|
+
state_changed: "the target state changed",
|
|
71
|
+
custody: "input delivery was already claimed or needs reconciliation",
|
|
72
|
+
stopped: "Shepherd stopped",
|
|
73
|
+
};
|
|
64
74
|
/** Kinds a "needs-me" owner still hears about. */
|
|
65
75
|
const NEEDS_ME = new Set(["gate", "done", "loop_guard"]);
|
|
66
76
|
function noticeWanted(policy, record) {
|
|
@@ -103,6 +113,8 @@ function noticeLine(record, location) {
|
|
|
103
113
|
const sent = record.reply.startsWith("key:") ? `pressed ${plain(record.reply.slice(4), 10)}` : `sent it back: "${plain(record.reply, REPLY_MAX)}"`;
|
|
104
114
|
return `${subject} stopped early, so I ${sent}. ${why}`;
|
|
105
115
|
}
|
|
116
|
+
if (record.actionBlockReason)
|
|
117
|
+
return `${subject} stopped early; no input sent because ${BLOCK_DESCRIPTION[record.actionBlockReason]}. ${why}`;
|
|
106
118
|
return `${subject} stopped early, but I did not answer it, so it is waiting for you. ${why}`;
|
|
107
119
|
case "gate":
|
|
108
120
|
return `${subject} needs you. ${why}`;
|
|
@@ -120,13 +132,30 @@ function noticeLine(record, location) {
|
|
|
120
132
|
return `${subject} stopped but I could not judge it, so I left it. ${why}`;
|
|
121
133
|
}
|
|
122
134
|
}
|
|
123
|
-
/**
|
|
124
|
-
function noticeMessage(records, refs = new Map()) {
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
135
|
+
/** A bounded display of latest observations; all input records retain delivery custody. */
|
|
136
|
+
function noticeMessage(records, refs = new Map(), context = {}) {
|
|
137
|
+
const latest = new Map();
|
|
138
|
+
for (const record of records) {
|
|
139
|
+
const key = JSON.stringify([record.host, record.session]);
|
|
140
|
+
const previous = latest.get(key);
|
|
141
|
+
const at = Date.parse(record.at);
|
|
142
|
+
const previousAt = previous ? Date.parse(previous.at) : NaN;
|
|
143
|
+
if (!previous || (Number.isFinite(at) && (!Number.isFinite(previousAt) || at > previousAt))
|
|
144
|
+
|| ((!Number.isFinite(at) && !Number.isFinite(previousAt) || at === previousAt) && record.transition > previous.transition))
|
|
145
|
+
latest.set(key, record);
|
|
146
|
+
}
|
|
147
|
+
const sessions = [...latest].sort(([a], [b]) => a < b ? -1 : 1).map(([, record]) => record);
|
|
148
|
+
const lines = sessions.slice(0, exports.NOTICE_LIST_MAX).map(record => {
|
|
149
|
+
const line = noticeLine(record, refs.get(record.session));
|
|
150
|
+
if (!context.recovered)
|
|
151
|
+
return line;
|
|
152
|
+
const at = Date.parse(record.at);
|
|
153
|
+
return `${line} (${Number.isFinite(at) ? `observed at ${new Date(at).toISOString()}` : "observation time unknown"})`;
|
|
154
|
+
});
|
|
155
|
+
const more = sessions.length - lines.length;
|
|
156
|
+
const body = sessions.length === 1 ? lines[0]
|
|
157
|
+
: [`Updates from ${sessions.length} coding sessions:`, ...lines.map(line => `- ${line}`), ...(more > 0 ? [`- and ${more} more.`] : [])].join("\n");
|
|
158
|
+
return context.recovered ? `Includes recovered historical observations, not live status:\n${body}` : body;
|
|
130
159
|
}
|
|
131
160
|
function teamsAadId(friend) {
|
|
132
161
|
const ext = friend.externalIds.find((entry) => entry.provider === "aad" && !entry.externalId.startsWith("group:"));
|
|
@@ -192,30 +221,43 @@ function createTeamsNoticeSender(agentRoot, deps = {}) {
|
|
|
192
221
|
return sendProactiveTeamsMessageToSession({ friendId: target.friendId, sessionKey: target.key, text, textFormat: "plain", intent: "generic_outreach", captureReceipt: true }, { botApi: await bot, store: new friends_1.FileFriendStore(path.join(agentRoot, "friends")) });
|
|
193
222
|
};
|
|
194
223
|
}
|
|
195
|
-
/** Collects
|
|
224
|
+
/** Collects durable notice intents; acceptance is distinct from transport delivery. */
|
|
196
225
|
function createOwnerNotifier(options) {
|
|
197
226
|
const { agent, policy } = options;
|
|
198
227
|
const resolveTargets = options.resolveTargets ?? (() => resolveNoticeTargets(options.agentRoot));
|
|
199
228
|
const send = options.send ?? createTeamsNoticeSender(options.agentRoot);
|
|
200
229
|
const retryWrites = new Map();
|
|
230
|
+
const recoveredNoticeIds = new Set();
|
|
201
231
|
const writes = new Set();
|
|
202
232
|
let cancel = null;
|
|
203
233
|
let stopped = false;
|
|
204
234
|
let flushing = null;
|
|
235
|
+
let drainRequested = false;
|
|
205
236
|
function schedule(ms) {
|
|
206
237
|
if (!stopped && policy !== "off")
|
|
207
|
-
cancel ??= options.schedule(() => {
|
|
238
|
+
cancel ??= options.schedule(() => {
|
|
239
|
+
cancel?.();
|
|
240
|
+
cancel = null;
|
|
241
|
+
if (flushing)
|
|
242
|
+
drainRequested = true;
|
|
243
|
+
else
|
|
244
|
+
void flush();
|
|
245
|
+
}, ms);
|
|
208
246
|
}
|
|
209
247
|
function persistIntent(record) {
|
|
210
248
|
const key = `${record.host}:${record.session}:${record.transition}:${record.at}`;
|
|
211
249
|
retryWrites.set(key, record);
|
|
212
|
-
const write = (0, notice_outbox_1.enqueueNotice)(options.agentRoot, record).then(
|
|
250
|
+
const write = (0, notice_outbox_1.enqueueNotice)(options.agentRoot, record).then(id => {
|
|
213
251
|
retryWrites.delete(key);
|
|
252
|
+
return id;
|
|
214
253
|
}).catch(error => {
|
|
215
254
|
(0, runtime_1.emitNervesEvent)({ level: "error", component: "senses", event: "senses.shepherd_notice_error", message: "could not persist a Shepherd owner notice intent; it remains waiting", meta: { agent, error: error.message } });
|
|
216
255
|
schedule(exports.NOTICE_RETRY_MS);
|
|
217
|
-
|
|
256
|
+
throw error;
|
|
257
|
+
});
|
|
218
258
|
writes.add(write);
|
|
259
|
+
// Observe cleanup immediately, while the producer still receives the rejecting promise.
|
|
260
|
+
void write.then(() => { writes.delete(write); }, () => { writes.delete(write); });
|
|
219
261
|
return write;
|
|
220
262
|
}
|
|
221
263
|
async function runFlush() {
|
|
@@ -223,10 +265,11 @@ function createOwnerNotifier(options) {
|
|
|
223
265
|
cancel = null;
|
|
224
266
|
if (stopped || policy === "off")
|
|
225
267
|
return;
|
|
226
|
-
await Promise.all([...writes]);
|
|
227
|
-
for (const record of [...retryWrites.values()])
|
|
228
|
-
await persistIntent(record);
|
|
229
268
|
try {
|
|
269
|
+
await Promise.allSettled([...writes]);
|
|
270
|
+
const retried = await Promise.allSettled([...retryWrites.values()].map(persistIntent));
|
|
271
|
+
if (retried.some(result => result.status === "rejected"))
|
|
272
|
+
schedule(exports.NOTICE_RETRY_MS);
|
|
230
273
|
const entries = (0, notice_outbox_1.readNoticeOutbox)(options.agentRoot).filter(entry => noticeWanted(policy, entry.record) && (Object.keys(entry.deliveries).length === 0 || Object.values(entry.deliveries).some(value => value.state !== "sent")));
|
|
231
274
|
if (entries.length === 0)
|
|
232
275
|
return;
|
|
@@ -239,6 +282,8 @@ function createOwnerNotifier(options) {
|
|
|
239
282
|
// Without the host's handles, each notice names a short session id instead.
|
|
240
283
|
}
|
|
241
284
|
const targets = await resolveTargets();
|
|
285
|
+
if (stopped)
|
|
286
|
+
return;
|
|
242
287
|
if (targets.length === 0) {
|
|
243
288
|
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_notice_no_target", message: "no family friend is reachable on Teams, so the Shepherd owner notice was not sent", meta: { agent, returns: entries.length } });
|
|
244
289
|
schedule(exports.NOTICE_RETRY_MS);
|
|
@@ -248,15 +293,21 @@ function createOwnerNotifier(options) {
|
|
|
248
293
|
const reachable = new Set(targets.map(target => target.friendId));
|
|
249
294
|
let retry = entries.some(entry => Object.entries(entry.deliveries).some(([friendId, delivery]) => delivery.state === "pending" && !reachable.has(friendId)));
|
|
250
295
|
for (const target of targets) {
|
|
296
|
+
if (stopped)
|
|
297
|
+
break;
|
|
251
298
|
const pending = entries.filter(entry => (0, notice_outbox_1.noticeDeliveryState)(entry, target.friendId) === "pending");
|
|
252
|
-
|
|
299
|
+
if (pending.length > 0) {
|
|
253
300
|
// Only the worker that atomically claims a pending entry may initiate transport.
|
|
254
|
-
const batch = await (0, notice_outbox_1.claimPendingNotices)(options.agentRoot, pending.
|
|
301
|
+
const batch = await (0, notice_outbox_1.claimPendingNotices)(options.agentRoot, pending.map(entry => entry.id), target.friendId, new Date().toISOString());
|
|
255
302
|
if (batch.length === 0)
|
|
256
303
|
continue;
|
|
257
304
|
const ids = batch.map(entry => entry.id);
|
|
305
|
+
if (stopped) {
|
|
306
|
+
await (0, notice_outbox_1.recordNoticeDelivery)(options.agentRoot, ids, target.friendId, { state: "pending", at: new Date().toISOString(), reason: "stopped before transport" });
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
258
309
|
try {
|
|
259
|
-
const result = await send(target, noticeMessage(batch.map(entry => entry.record), refs));
|
|
310
|
+
const result = await send(target, noticeMessage(batch.map(entry => entry.record), refs, { recovered: ids.some(id => recoveredNoticeIds.has(id)) }));
|
|
260
311
|
const state = result.delivered ? "sent"
|
|
261
312
|
: result.delivery ? result.delivery.state === "not_sent" ? "pending" : "unknown"
|
|
262
313
|
: result.reason && result.reason !== "send_error" ? "pending" : "unknown";
|
|
@@ -288,19 +339,20 @@ function createOwnerNotifier(options) {
|
|
|
288
339
|
function flush() {
|
|
289
340
|
if (flushing)
|
|
290
341
|
return flushing;
|
|
291
|
-
flushing = runFlush().finally(() => {
|
|
342
|
+
flushing = runFlush().finally(() => {
|
|
343
|
+
flushing = null;
|
|
344
|
+
if (drainRequested) {
|
|
345
|
+
drainRequested = false;
|
|
346
|
+
schedule(exports.NOTICE_COALESCE_MS);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
292
349
|
return flushing;
|
|
293
350
|
}
|
|
294
351
|
try {
|
|
295
352
|
const existing = (0, notice_outbox_1.readNoticeOutbox)(options.agentRoot);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
void persistIntent(record);
|
|
300
|
-
recovered = true;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
if (recovered || existing.some(entry => Object.keys(entry.deliveries).length === 0 || Object.values(entry.deliveries).some(value => value.state === "pending")))
|
|
353
|
+
for (const entry of existing)
|
|
354
|
+
recoveredNoticeIds.add(entry.id);
|
|
355
|
+
if (existing.some(entry => Object.keys(entry.deliveries).length === 0 || Object.values(entry.deliveries).some(value => value.state === "pending")))
|
|
304
356
|
schedule(exports.NOTICE_RETRY_MS);
|
|
305
357
|
}
|
|
306
358
|
catch (error) {
|
|
@@ -309,11 +361,13 @@ function createOwnerNotifier(options) {
|
|
|
309
361
|
}
|
|
310
362
|
return {
|
|
311
363
|
notify(record) {
|
|
312
|
-
if (stopped
|
|
313
|
-
return Promise.resolve();
|
|
364
|
+
if (stopped)
|
|
365
|
+
return Promise.resolve({ state: "suppressed", reason: "stopped" });
|
|
366
|
+
if (!noticeWanted(policy, record))
|
|
367
|
+
return Promise.resolve({ state: "suppressed", reason: "policy" });
|
|
314
368
|
const write = persistIntent(record);
|
|
315
369
|
schedule(exports.NOTICE_COALESCE_MS);
|
|
316
|
-
return write;
|
|
370
|
+
return write.then(noticeId => ({ state: "queued", noticeId }));
|
|
317
371
|
},
|
|
318
372
|
flush,
|
|
319
373
|
stop() {
|
|
@@ -368,6 +368,7 @@ async function startShepherdSenseApp(options) {
|
|
|
368
368
|
guidanceRevision: String(record.guidanceRevision), reply: "key" in reply ? `key:${reply.key}` : reply.text,
|
|
369
369
|
trigger: record.trigger, transition: record.transition, at: record.at }, dispatchOwner);
|
|
370
370
|
if (!claim) {
|
|
371
|
+
record.actionBlockReason = "custody";
|
|
371
372
|
record.reason = `durable dispatch custody refused duplicate or unresolved input: ${record.reason}`;
|
|
372
373
|
return false;
|
|
373
374
|
}
|
|
@@ -385,6 +386,7 @@ async function startShepherdSenseApp(options) {
|
|
|
385
386
|
}
|
|
386
387
|
}
|
|
387
388
|
catch (error) {
|
|
389
|
+
record.actionBlockReason = "custody";
|
|
388
390
|
record.dispatch.state = "unknown";
|
|
389
391
|
try {
|
|
390
392
|
custody.completeDispatch(claim.id, dispatchOwner, { state: "unknown", reason: field((0, redact_1.redactSecrets)(String(error)), 200) });
|
|
@@ -399,6 +401,7 @@ async function startShepherdSenseApp(options) {
|
|
|
399
401
|
...(result.reason ? { reason: field((0, redact_1.redactSecrets)(result.reason), 200) } : {}) });
|
|
400
402
|
record.dispatch.state = result.state;
|
|
401
403
|
if (result.state === "not_sent") {
|
|
404
|
+
record.actionBlockReason = "state_changed";
|
|
402
405
|
record.reason = `guarded host refused before input: ${result.reason ?? "target changed"}; ${record.reason}`;
|
|
403
406
|
return false;
|
|
404
407
|
}
|
|
@@ -420,18 +423,26 @@ async function startShepherdSenseApp(options) {
|
|
|
420
423
|
/** False for a return nothing new was decided about: the loop guard still holds, or the same background wait. */
|
|
421
424
|
let notice = true;
|
|
422
425
|
let noticeSignature = null;
|
|
426
|
+
let observationSignature = null;
|
|
427
|
+
let noticeBasis = null;
|
|
428
|
+
let status = null;
|
|
429
|
+
let quietWorkingStatus = false;
|
|
430
|
+
let escalateOutcome = false;
|
|
431
|
+
let wakeOutcome = false;
|
|
423
432
|
let judged = false;
|
|
424
433
|
try {
|
|
425
434
|
let current;
|
|
426
435
|
if (periodic) {
|
|
427
436
|
notice = false;
|
|
428
437
|
if (periodic.generation !== generationFor(event.sessionId) || now() - periodic.observedAt < cmux_1.QUIET_MS) {
|
|
438
|
+
record.actionBlockReason = "state_changed";
|
|
429
439
|
record.reason = "the check-in observation was invalidated or has not been quiet long enough";
|
|
430
440
|
return;
|
|
431
441
|
}
|
|
432
442
|
current = await host.inspect(event.sessionId);
|
|
433
443
|
if (stopped || periodic.generation !== generationFor(event.sessionId) ||
|
|
434
444
|
inspectionFingerprint(current) !== inspectionFingerprint(periodic.first) || current.session.focused) {
|
|
445
|
+
record.actionBlockReason = stopped ? "stopped" : current.session.focused ? "focus" : "state_changed";
|
|
435
446
|
record.reason = "the check-in target changed during the quiet interval";
|
|
436
447
|
return;
|
|
437
448
|
}
|
|
@@ -440,6 +451,7 @@ async function startShepherdSenseApp(options) {
|
|
|
440
451
|
if (state.tripped) {
|
|
441
452
|
// Tripped: no judge call and no new escalation until the human looks at the session.
|
|
442
453
|
Object.assign(record, { kind: "loop_guard", reason: `Shepherd stopped answering this session until ${human} focuses it` });
|
|
454
|
+
record.actionBlockReason = "loop_guard";
|
|
443
455
|
notice = false;
|
|
444
456
|
return;
|
|
445
457
|
}
|
|
@@ -456,11 +468,21 @@ async function startShepherdSenseApp(options) {
|
|
|
456
468
|
record.task = [...text.matchAll(/Desk-Task:\s*(\S+)/g)].pop()?.[1] ?? null;
|
|
457
469
|
const screen = (0, redact_1.screenTail)(text, exports.SCREEN_LINES);
|
|
458
470
|
if (recovery && screen !== recovery.screen) {
|
|
471
|
+
record.actionBlockReason = "state_changed";
|
|
459
472
|
record.reason = "the screen changed since the network stop, so recovery was not sent";
|
|
460
473
|
return;
|
|
461
474
|
}
|
|
462
475
|
const target = { host: host.name, session: event.sessionId, cwd: record.cwd, task: record.task };
|
|
463
|
-
const
|
|
476
|
+
const checkedGuidance = () => {
|
|
477
|
+
try {
|
|
478
|
+
return guidanceFor(target);
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
record.actionBlockReason = "guidance";
|
|
482
|
+
throw error;
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
const guidance = checkedGuidance();
|
|
464
486
|
record.guidanceRevision = guidance.revision;
|
|
465
487
|
let verdict;
|
|
466
488
|
try {
|
|
@@ -478,117 +500,172 @@ async function startShepherdSenseApp(options) {
|
|
|
478
500
|
}
|
|
479
501
|
judged = true;
|
|
480
502
|
if (periodic && (stopped || periodic.generation !== generationFor(event.sessionId))) {
|
|
503
|
+
record.actionBlockReason = stopped ? "stopped" : "state_changed";
|
|
481
504
|
record.reason = "the check-in was invalidated during judgment; no input or notice sent";
|
|
482
505
|
return;
|
|
483
506
|
}
|
|
484
507
|
// The host's own word that background work is running outranks a reading of the screen: such a stop is never a return.
|
|
485
508
|
const kind = (current ? current.backgroundWork : event.backgroundWork) ? "working" : verdict.kind;
|
|
486
509
|
const news = verdict.news === true || (kind === "working" && verdict.kind === "gate");
|
|
487
|
-
Object.assign(record, { kind, reason: verdict.reason, latencyMs: verdict.latencyMs, inputTokens: verdict.inputTokens
|
|
510
|
+
Object.assign(record, { kind, judgeKind: verdict.kind, reason: verdict.reason, latencyMs: verdict.latencyMs, inputTokens: verdict.inputTokens,
|
|
511
|
+
...(verdict.replyIssue ? { replyIssue: verdict.replyIssue } : {}) });
|
|
488
512
|
if (!recovery && !periodic && kind === "waiting" && verdict.waitingOnNetwork)
|
|
489
513
|
rememberNetworkWait(event, screen);
|
|
490
|
-
|
|
491
|
-
|
|
514
|
+
const noticeScreen = screen.replace(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\b/g, "<observation-time>");
|
|
515
|
+
noticeBasis = [host.name, event.sessionId, record.agent, record.cwd, record.task, noticeScreen, guidance.revision];
|
|
516
|
+
notice = true;
|
|
492
517
|
// A terminal that keeps going quiet without needing anything is checked less often.
|
|
493
518
|
if (!periodic)
|
|
494
519
|
host.pace?.(event.sessionId, kind === "working" || kind === "unclear");
|
|
495
520
|
state.answers = state.answers.filter((at) => now() - at < 3_600_000);
|
|
496
521
|
if (kind === "working") {
|
|
497
522
|
// Still working: a progress update, never an answer. It reaches the Ouro agent only when it carries news, and wakes nothing.
|
|
498
|
-
if (news
|
|
499
|
-
|
|
523
|
+
if (news)
|
|
524
|
+
escalateOutcome = true;
|
|
500
525
|
else
|
|
501
526
|
notice = false;
|
|
502
|
-
|
|
503
|
-
|
|
527
|
+
quietWorkingStatus = !periodic && !news;
|
|
528
|
+
status = `${record.agent ?? "agent"} waiting on its background work: ${verdict.reason}`;
|
|
504
529
|
}
|
|
505
|
-
|
|
506
|
-
|
|
530
|
+
const actionableReply = verdict.reply ?? (periodic && verdict.replyIssue === "too_long" ? { text: exports.PERIODIC_RESUME } : null);
|
|
531
|
+
if (kind === "premature" && !actionableReply)
|
|
532
|
+
record.actionBlockReason = "reply_invalid";
|
|
533
|
+
if (kind === "premature" && actionableReply) {
|
|
534
|
+
if ((recovery || periodic) && "key" in actionableReply) {
|
|
535
|
+
record.actionBlockReason = "menu_disallowed";
|
|
507
536
|
record.reason = `${periodic ? "periodic check-in" : "connectivity recovery"} never presses approval or menu keys: ${verdict.reason}`;
|
|
508
537
|
}
|
|
509
538
|
else if (periodic && (!current.instance || current.activity !== "idle" || current.composer !== "empty" ||
|
|
510
539
|
current.backgroundWork !== false || !current.session.agent || !host.promptIfUnchanged)) {
|
|
540
|
+
record.actionBlockReason = "host_unverified";
|
|
511
541
|
record.reason = `the host cannot verify an idle, empty, supported agent process for guarded input: ${verdict.reason}`;
|
|
512
542
|
}
|
|
513
543
|
else if (guidance.paused) {
|
|
544
|
+
record.actionBlockReason = "guidance";
|
|
514
545
|
record.reason = `Shepherd guidance has this session paused, so nothing was sent: ${verdict.reason}`;
|
|
515
546
|
}
|
|
516
547
|
else if (state.streak >= exports.STREAK_MAX || state.answers.length >= exports.HOURLY_MAX) {
|
|
517
548
|
Object.assign(record, { kind: "loop_guard", reason: `loop guard: ${verdict.reason}` });
|
|
549
|
+
record.actionBlockReason = "loop_guard";
|
|
518
550
|
state.tripped = true;
|
|
519
551
|
}
|
|
520
552
|
else {
|
|
521
553
|
const sessions = await host.list();
|
|
522
554
|
const session = sessions.find(session => session.id === event.sessionId);
|
|
523
555
|
if (now() - state.focusedAt < exports.FOCUS_HOLD_MS || session?.focused) {
|
|
556
|
+
record.actionBlockReason = "focus";
|
|
524
557
|
// Never type into the session the human is in, or one they just left.
|
|
525
558
|
record.reason = `${human} is in this session or just left it, so it is theirs: ${verdict.reason}`;
|
|
526
559
|
}
|
|
527
560
|
else if (periodic && (!session || session.agent !== current.session.agent || session.cwd !== current.session.cwd)) {
|
|
561
|
+
record.actionBlockReason = "state_changed";
|
|
528
562
|
record.reason = `the check-in target changed while judging, so nothing was sent: ${verdict.reason}`;
|
|
529
563
|
}
|
|
530
564
|
else if (!periodic && (0, redact_1.withoutNumbers)((0, redact_1.screenTail)((0, redact_1.redactSecrets)(await host.read(event.sessionId, exports.READ_LINES)), exports.SCREEN_LINES)) !== (0, redact_1.withoutNumbers)(screen)) {
|
|
565
|
+
record.actionBlockReason = "state_changed";
|
|
531
566
|
record.reason = `the screen changed while judging, so nothing was sent: ${verdict.reason}`;
|
|
532
567
|
}
|
|
533
568
|
else {
|
|
534
569
|
const finalInspection = periodic ? await host.inspect(event.sessionId) : undefined;
|
|
535
570
|
if (periodic && (stopped || periodic.generation !== generationFor(event.sessionId) ||
|
|
536
571
|
finalInspection.session.focused || inspectionFingerprint(finalInspection) !== inspectionFingerprint(current))) {
|
|
572
|
+
record.actionBlockReason = stopped ? "stopped" : finalInspection.session.focused ? "focus" : "state_changed";
|
|
537
573
|
record.reason = `the check-in changed while judging, so nothing was sent: ${verdict.reason}`;
|
|
538
574
|
return;
|
|
539
575
|
}
|
|
540
576
|
// No awaited host reads between this final check and initiating the send.
|
|
541
|
-
const latest =
|
|
577
|
+
const latest = checkedGuidance();
|
|
542
578
|
if (stopped || (recovery && (latestReturns.get(event.sessionId) !== event || now() - state.focusedAt < exports.FOCUS_HOLD_MS)) ||
|
|
543
579
|
periodic && (periodic.generation !== generationFor(event.sessionId) || now() - state.focusedAt < exports.FOCUS_HOLD_MS)) {
|
|
580
|
+
record.actionBlockReason = stopped ? "stopped" : now() - state.focusedAt < exports.FOCUS_HOLD_MS ? "focus" : "state_changed";
|
|
544
581
|
record.reason = `Shepherd stopped, the human focused this session, or it returned again while judging, so nothing was sent: ${verdict.reason}`;
|
|
545
582
|
}
|
|
546
583
|
else if (latest.paused || latest.revision !== guidance.revision) {
|
|
584
|
+
record.actionBlockReason = "guidance";
|
|
547
585
|
record.reason = `Shepherd guidance paused or changed while judging (revision ${guidance.revision} to ${latest.revision}), so nothing was sent: ${verdict.reason}`;
|
|
548
586
|
}
|
|
549
587
|
else {
|
|
550
|
-
const reply = periodic ? { text: exports.PERIODIC_RESUME } : recovery ? { text: NETWORK_RESUME } :
|
|
588
|
+
const reply = periodic ? { text: exports.PERIODIC_RESUME } : recovery ? { text: NETWORK_RESUME } : actionableReply;
|
|
551
589
|
const canSend = periodic ? () => {
|
|
552
590
|
if (stopped || periodic.generation !== generationFor(event.sessionId) || now() - state.focusedAt < exports.FOCUS_HOLD_MS)
|
|
553
591
|
return false;
|
|
554
|
-
const live =
|
|
592
|
+
const live = checkedGuidance();
|
|
555
593
|
return !live.paused && live.revision === guidance.revision;
|
|
556
594
|
} : undefined;
|
|
557
595
|
if (await dispatch(record, reply, current?.instance ?? session?.instance ?? null, fingerprint(screen), periodic ? current : undefined, canSend)) {
|
|
558
596
|
Object.assign(record, { action: "respond", reply: "key" in reply ? `key:${reply.key}` : reply.text });
|
|
559
597
|
state.streak += 1;
|
|
560
598
|
state.answers.push(now());
|
|
561
|
-
|
|
562
|
-
await host.signal(event.sessionId, field(`answered for ${human}: ${verdict.reason}`, 120));
|
|
599
|
+
status = `answered for ${human}: ${verdict.reason}`;
|
|
563
600
|
}
|
|
564
601
|
}
|
|
565
602
|
}
|
|
566
603
|
}
|
|
567
604
|
}
|
|
568
|
-
if (
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
605
|
+
if (record.kind === "gate" || record.kind === "done" || record.kind === "waiting" || record.kind === "loop_guard") {
|
|
606
|
+
escalateOutcome = true;
|
|
607
|
+
wakeOutcome = true;
|
|
608
|
+
status = `${record.kind === "gate" ? `needs ${human}` : record.kind === "done" ? "done" : record.kind === "waiting" ? "waiting on an outside dependency" : "stopped answering"}: ${verdict.reason}`;
|
|
572
609
|
}
|
|
573
610
|
}
|
|
574
611
|
catch (error) {
|
|
575
612
|
Object.assign(record, { kind: record.reason ? record.kind : "error", reason: `${record.reason ? `${record.reason}; then ` : ""}${error.message}` });
|
|
576
613
|
}
|
|
577
614
|
finally {
|
|
615
|
+
if (noticeBasis && notice) {
|
|
616
|
+
observationSignature = fingerprint([...noticeBasis, record.kind, record.actionBlockReason === "custody" ? null : record.actionBlockReason ?? null]);
|
|
617
|
+
noticeSignature = record.action === "respond" ? fingerprint([observationSignature, "respond", record.reply])
|
|
618
|
+
: record.dispatch?.state === "unknown" ? fingerprint([observationSignature, "unknown"]) : observationSignature;
|
|
619
|
+
try {
|
|
620
|
+
notice = custody.shouldNotify(noticeSignature);
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
notice = false;
|
|
624
|
+
record.noticeState = "failed";
|
|
625
|
+
(0, runtime_1.emitNervesEvent)({ level: "error", component: "senses", event: "senses.shepherd_notice_error", message: "could not compare owner-notice suppression; no new delivery intent accepted", meta: { agent, reason: "signature_read_failed", error: error.message } });
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
try {
|
|
629
|
+
if (notice && escalateOutcome) {
|
|
630
|
+
escalate(escalationContent(record, human));
|
|
631
|
+
if (wakeOutcome)
|
|
632
|
+
requestWake(record.transition);
|
|
633
|
+
}
|
|
634
|
+
if (status && (notice || quietWorkingStatus))
|
|
635
|
+
await host.signal(event.sessionId, field(status, 120));
|
|
636
|
+
}
|
|
637
|
+
catch (error) {
|
|
638
|
+
record.reason += `; outcome signaling failed: ${error.message}`;
|
|
639
|
+
}
|
|
578
640
|
record.noticeEligible = notice;
|
|
579
|
-
log(record);
|
|
580
|
-
if (judged || !periodic)
|
|
581
|
-
history.set(event.sessionId, record);
|
|
582
641
|
if (notice) {
|
|
583
642
|
try {
|
|
584
|
-
await notifyOwner(record);
|
|
585
|
-
|
|
586
|
-
|
|
643
|
+
const acceptance = await notifyOwner(record);
|
|
644
|
+
record.noticeState = acceptance.state;
|
|
645
|
+
if (acceptance.state === "queued")
|
|
646
|
+
record.noticeId = acceptance.noticeId;
|
|
647
|
+
else
|
|
648
|
+
record.noticeSuppressionReason = acceptance.reason;
|
|
649
|
+
if (noticeSignature && (acceptance.state === "queued" || acceptance.reason === "policy")) {
|
|
650
|
+
try {
|
|
651
|
+
custody.markNotified(noticeSignature, record.at);
|
|
652
|
+
// A sent/uncertain effect also settles the unchanged observation, not just that action outcome.
|
|
653
|
+
if (observationSignature && observationSignature !== noticeSignature)
|
|
654
|
+
custody.markNotified(observationSignature, record.at);
|
|
655
|
+
}
|
|
656
|
+
catch (error) {
|
|
657
|
+
(0, runtime_1.emitNervesEvent)({ level: "error", component: "senses", event: "senses.shepherd_notice_error", message: "owner notice was accepted but its suppression signature could not be recorded", meta: { agent, error: error.message } });
|
|
658
|
+
}
|
|
659
|
+
}
|
|
587
660
|
}
|
|
588
661
|
catch (error) {
|
|
589
|
-
|
|
662
|
+
record.noticeState = "failed";
|
|
663
|
+
(0, runtime_1.emitNervesEvent)({ level: "error", component: "senses", event: "senses.shepherd_notice_error", message: "owner notice custody failed; the observation is not accepted delivery intent", meta: { agent, error: error.message } });
|
|
590
664
|
}
|
|
591
665
|
}
|
|
666
|
+
log(record);
|
|
667
|
+
if (judged || !periodic)
|
|
668
|
+
history.set(event.sessionId, record);
|
|
592
669
|
}
|
|
593
670
|
}
|
|
594
671
|
function guidanceFor(target) {
|
package/dist/senses/teams.js
CHANGED
|
@@ -72,6 +72,7 @@ const tool_activity_callbacks_1 = require("../heart/tool-activity-callbacks");
|
|
|
72
72
|
const commands_2 = require("./commands");
|
|
73
73
|
const http = __importStar(require("http"));
|
|
74
74
|
const path = __importStar(require("path"));
|
|
75
|
+
const node_crypto_1 = require("node:crypto");
|
|
75
76
|
const trust_gate_1 = require("./trust-gate");
|
|
76
77
|
const pipeline_1 = require("./pipeline");
|
|
77
78
|
const session_transaction_1 = require("../mind/session-transaction");
|
|
@@ -195,6 +196,11 @@ function buildWelcomeCard() {
|
|
|
195
196
|
// safeSend (ctx.send). Tool results, kicks, and errors use safeUpdate
|
|
196
197
|
// (transient status) or safeSend (terminal errors). Reasoning is accumulated
|
|
197
198
|
// and periodically pushed via safeUpdate on the same flush timer tick.
|
|
199
|
+
function isTeamsSizeRejection(error) {
|
|
200
|
+
return typeof error === "object" && error !== null && "response" in error
|
|
201
|
+
&& typeof error.response === "object" && error.response !== null && "status" in error.response
|
|
202
|
+
&& error.response.status === 413;
|
|
203
|
+
}
|
|
198
204
|
function createTeamsCallbacks(stream, controller, sendMessage, options) {
|
|
199
205
|
const MIN_INITIAL_CHARS = 20;
|
|
200
206
|
let stopped = false; // set when stream signals cancellation (403)
|
|
@@ -298,18 +304,22 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
|
|
|
298
304
|
/* v8 ignore next -- defensive guard: stopped set by prior error; tested via flush abort path @preserve */
|
|
299
305
|
if (stopped)
|
|
300
306
|
return false;
|
|
307
|
+
let awaitingTransport = false;
|
|
301
308
|
try {
|
|
302
309
|
// stream.emit() is typed as void but the Teams SDK returns a Promise
|
|
303
310
|
// internally (async HTTP). Cast to capture the result for awaiting.
|
|
304
311
|
const result = stream.emit(streamChunk(text));
|
|
305
312
|
streamHasContent = true;
|
|
306
313
|
if (result && typeof result.then === "function") {
|
|
314
|
+
awaitingTransport = true;
|
|
307
315
|
await result;
|
|
308
316
|
}
|
|
309
317
|
return true;
|
|
310
318
|
}
|
|
311
319
|
catch (err) {
|
|
312
320
|
markStopped("tryEmit", err);
|
|
321
|
+
if (awaitingTransport && !isTeamsSizeRejection(err))
|
|
322
|
+
throw err;
|
|
313
323
|
return false;
|
|
314
324
|
}
|
|
315
325
|
}
|
|
@@ -321,15 +331,21 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
|
|
|
321
331
|
// which has a sendMessage fallback that may still succeed. Caller (flushNow) is
|
|
322
332
|
// responsible for the `!stopped` precondition; no defensive guard here.
|
|
323
333
|
async function tryEmitNoAbort(text) {
|
|
334
|
+
let awaitingTransport = false;
|
|
324
335
|
try {
|
|
325
336
|
const result = stream.emit(streamChunk(text));
|
|
326
337
|
streamHasContent = true;
|
|
327
338
|
if (result && typeof result.then === "function") {
|
|
339
|
+
awaitingTransport = true;
|
|
328
340
|
await result;
|
|
329
341
|
}
|
|
330
342
|
return true;
|
|
331
343
|
}
|
|
332
|
-
catch {
|
|
344
|
+
catch (error) {
|
|
345
|
+
if (awaitingTransport && !isTeamsSizeRejection(error)) {
|
|
346
|
+
markStopped("speak-unknown", error);
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
333
349
|
return false;
|
|
334
350
|
}
|
|
335
351
|
}
|
|
@@ -606,7 +622,10 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
|
|
|
606
622
|
try {
|
|
607
623
|
await sendMessage(text);
|
|
608
624
|
}
|
|
609
|
-
catch {
|
|
625
|
+
catch (error) {
|
|
626
|
+
// A lost acknowledgement is not a rejection. Only a definite size rejection permits a smaller retry.
|
|
627
|
+
if (!isTeamsSizeRejection(error) || text.length <= RECOVERY_CHUNK_SIZE)
|
|
628
|
+
throw error;
|
|
610
629
|
const chunks = splitMessage(text, RECOVERY_CHUNK_SIZE);
|
|
611
630
|
for (const chunk of chunks)
|
|
612
631
|
await sendMessage(chunk);
|
|
@@ -636,19 +655,34 @@ const _turnCoordinator = (0, turn_coordinator_1.createTurnCoordinator)();
|
|
|
636
655
|
function teamsTurnKey(conversationId) {
|
|
637
656
|
return `teams:${conversationId}`;
|
|
638
657
|
}
|
|
658
|
+
function teamsAdmissionKey(conversationId) {
|
|
659
|
+
return `teams-admission:${conversationId}`;
|
|
660
|
+
}
|
|
639
661
|
const queuedTeamsTurns = new Map();
|
|
640
662
|
const activeTeamsActors = new Map();
|
|
663
|
+
const consumingTeamsSteering = new Set();
|
|
664
|
+
function drainTeamsSteering(key) {
|
|
665
|
+
const followUps = _turnCoordinator.drainFollowUps(key);
|
|
666
|
+
const consumed = new Set(followUps);
|
|
667
|
+
const queue = queuedTeamsTurns.get(key);
|
|
668
|
+
if (queue)
|
|
669
|
+
queuedTeamsTurns.set(key, queue.filter(turn => !turn.steering || !consumed.has(turn.steering)));
|
|
670
|
+
return followUps;
|
|
671
|
+
}
|
|
641
672
|
/** Context-bearing replies cannot steer an already-running tool loop. Keep the
|
|
642
673
|
* original SDK context/transport alive until a fresh handleTeamsMessage lease.
|
|
643
674
|
* Ordinary same-actor steering still uses the existing coordinator buffer. */
|
|
644
675
|
async function coordinateTeamsTurn(conversationId, text, identity, run) {
|
|
645
676
|
const key = teamsTurnKey(conversationId);
|
|
646
|
-
const actorKey = JSON.stringify([identity.aadObjectId
|
|
677
|
+
const actorKey = JSON.stringify([identity.aadObjectId || identity.userId || "", identity.tenantId ?? ""]);
|
|
647
678
|
if (!_turnCoordinator.tryBeginTurn(key)) {
|
|
648
|
-
if (!identity.replyToId && !/\bfeedback:/i.test(text) &&
|
|
649
|
-
activeTeamsActors.get(key) === actorKey && !queuedTeamsTurns.get(key)?.
|
|
650
|
-
|
|
651
|
-
|
|
679
|
+
if (!identity.replyToId && !/\bfeedback:/i.test(text) && consumingTeamsSteering.has(key) &&
|
|
680
|
+
activeTeamsActors.get(key) === actorKey && !queuedTeamsTurns.get(key)?.some(turn => !turn.steering)) {
|
|
681
|
+
const steering = { conversationId, text, receivedAt: Date.now(), effect: (0, continuity_1.classifySteeringFollowUpEffect)(text) };
|
|
682
|
+
_turnCoordinator.enqueueFollowUp(key, steering);
|
|
683
|
+
const queue = queuedTeamsTurns.get(key) ?? [];
|
|
684
|
+
queue.push({ actorKey, run, steering, resolve: () => { }, reject: () => { } });
|
|
685
|
+
queuedTeamsTurns.set(key, queue);
|
|
652
686
|
return;
|
|
653
687
|
}
|
|
654
688
|
await new Promise((resolve, reject) => {
|
|
@@ -664,6 +698,12 @@ async function coordinateTeamsTurn(conversationId, text, identity, run) {
|
|
|
664
698
|
try {
|
|
665
699
|
while (next) {
|
|
666
700
|
activeTeamsActors.set(key, next.actorKey);
|
|
701
|
+
if (next.steering) {
|
|
702
|
+
for (const followUp of _turnCoordinator.drainFollowUps(key)) {
|
|
703
|
+
if (followUp !== next.steering)
|
|
704
|
+
_turnCoordinator.enqueueFollowUp(key, followUp);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
667
707
|
try {
|
|
668
708
|
await next.run();
|
|
669
709
|
next.resolve();
|
|
@@ -674,6 +714,8 @@ async function coordinateTeamsTurn(conversationId, text, identity, run) {
|
|
|
674
714
|
next.reject(error);
|
|
675
715
|
}
|
|
676
716
|
first = false;
|
|
717
|
+
// Preserve received order while identity/admission I/O finishes, without holding admission through a model turn.
|
|
718
|
+
await _turnCoordinator.withTurnLock(teamsAdmissionKey(conversationId), async () => { });
|
|
677
719
|
next = queuedTeamsTurns.get(key)?.shift();
|
|
678
720
|
}
|
|
679
721
|
}
|
|
@@ -694,6 +736,14 @@ function getFriendStore() {
|
|
|
694
736
|
const friendsPath = path.join((0, identity_1.getAgentRoot)(), "friends");
|
|
695
737
|
return new friends_1.FileFriendStore(friendsPath);
|
|
696
738
|
}
|
|
739
|
+
function reportTeamsRequest(stage, conversationId, context, details = {}) {
|
|
740
|
+
if (!context?.inboundActivityId)
|
|
741
|
+
return;
|
|
742
|
+
(0, runtime_1.emitNervesEvent)({
|
|
743
|
+
component: "channels", event: "channel.teams_request_stage", message: "Teams conversational request stage",
|
|
744
|
+
meta: { stage, inboundActivityId: context.inboundActivityId, conversationId, tenantId: context.tenantId, botId: context.botApi?.id, ...details },
|
|
745
|
+
});
|
|
746
|
+
}
|
|
697
747
|
function createTeamsCommandRegistry() {
|
|
698
748
|
const registry = (0, commands_1.createCommandRegistry)();
|
|
699
749
|
(0, commands_1.registerDefaultCommands)(registry);
|
|
@@ -750,14 +800,24 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
|
|
|
750
800
|
// ── Teams adapter concerns: controller, callbacks, session path ──────────
|
|
751
801
|
const controller = new AbortController();
|
|
752
802
|
const channelConfig = (0, config_2.getTeamsChannelConfig)();
|
|
753
|
-
const callbacks = createTeamsCallbacks(stream, controller, sendMessage, { conversationId, flushIntervalMs: channelConfig.flushIntervalMs, ...(reactionOverrides?.suppressEmptyStreamMessage ? { suppressEmptyStreamMessage: true } : {}) });
|
|
754
803
|
const traceId = (0, nerves_1.createTraceId)();
|
|
804
|
+
const observedStream = {
|
|
805
|
+
emit(activity) {
|
|
806
|
+
const result = stream.emit(activity);
|
|
807
|
+
reportTeamsRequest("reply_emitted", conversationId, teamsContext, { traceId, delivery: "unconfirmed_stream" });
|
|
808
|
+
return result;
|
|
809
|
+
},
|
|
810
|
+
update(text) { return stream.update(text); },
|
|
811
|
+
close: stream.close.bind(stream),
|
|
812
|
+
};
|
|
813
|
+
const callbacks = createTeamsCallbacks(observedStream, controller, sendMessage, { conversationId, flushIntervalMs: channelConfig.flushIntervalMs, ...(reactionOverrides?.suppressEmptyStreamMessage ? { suppressEmptyStreamMessage: true } : {}) });
|
|
755
814
|
const sessPath = (0, config_2.sessionPath)(friendId, "teams", conversationId);
|
|
756
815
|
const teamsCapabilities = (0, friends_1.getChannelCapabilities)("teams");
|
|
757
816
|
const pendingDir = (0, pending_1.getPendingDir)(agentName, friendId, "teams", conversationId);
|
|
758
817
|
const runWithLease = runtimeOverrides?._withSessionTurnLease ?? session_transaction_1.withSessionTurnLease;
|
|
759
818
|
try {
|
|
760
819
|
await runWithLease(sessPath, async (sessionTurnLease) => {
|
|
820
|
+
reportTeamsRequest("running", conversationId, teamsContext, { traceId, friendId });
|
|
761
821
|
// Proactive feedback acknowledgements are not magically in this model session's
|
|
762
822
|
// history. Recover their frozen packet only after identity and lease ownership.
|
|
763
823
|
let feedbackReply;
|
|
@@ -768,7 +828,7 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
|
|
|
768
828
|
catch (error) {
|
|
769
829
|
if (!(error instanceof feedback_reply_1.FeedbackReplyRefusal))
|
|
770
830
|
throw error;
|
|
771
|
-
stream.emit(error.message);
|
|
831
|
+
await stream.emit(error.message);
|
|
772
832
|
return;
|
|
773
833
|
}
|
|
774
834
|
// Do not emit any activity until this turn owns the durable session.
|
|
@@ -800,7 +860,7 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
|
|
|
800
860
|
},
|
|
801
861
|
mcpManager,
|
|
802
862
|
drainSteeringFollowUps: () => {
|
|
803
|
-
drainedSteeringFollowUps =
|
|
863
|
+
drainedSteeringFollowUps = drainTeamsSteering(turnKey)
|
|
804
864
|
.map(({ text: followUpText, effect }) => ({ text: followUpText, effect }));
|
|
805
865
|
return drainedSteeringFollowUps;
|
|
806
866
|
},
|
|
@@ -863,16 +923,24 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
|
|
|
863
923
|
enforceTrustGate: trust_gate_1.enforceTrustGate,
|
|
864
924
|
drainPending: pending_1.drainPending,
|
|
865
925
|
drainDeferredReturns: (deferredFriendId) => (0, pending_1.drainDeferredReturns)(agentName, deferredFriendId),
|
|
866
|
-
runAgent: (msgs, cb, channel, sig, opts) =>
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
926
|
+
runAgent: async (msgs, cb, channel, sig, opts) => {
|
|
927
|
+
consumingTeamsSteering.add(turnKey);
|
|
928
|
+
try {
|
|
929
|
+
return await (0, core_1.runAgent)(msgs, cb, channel, sig, {
|
|
930
|
+
...opts,
|
|
931
|
+
toolContext: {
|
|
932
|
+
/* v8 ignore next -- default no-op signin; pipeline provides the real one @preserve */
|
|
933
|
+
signin: async () => undefined,
|
|
934
|
+
...opts?.toolContext,
|
|
935
|
+
...owner,
|
|
936
|
+
summarize: teamsToolContext.summarize,
|
|
937
|
+
},
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
finally {
|
|
941
|
+
consumingTeamsSteering.delete(turnKey);
|
|
942
|
+
}
|
|
943
|
+
},
|
|
876
944
|
postTurn: (turnMessages, sessionPathArg, usage, hooks, state) => {
|
|
877
945
|
const prepared = (0, context_1.postTurnTrim)(turnMessages, usage, hooks);
|
|
878
946
|
return (0, context_1.deferPostTurnPersist)(sessionPathArg, prepared, usage, state);
|
|
@@ -898,7 +966,7 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
|
|
|
898
966
|
if (result.turnOutcome === "command") {
|
|
899
967
|
if (result.commandAction === "new") {
|
|
900
968
|
(0, context_1.deleteSession)(sessPath);
|
|
901
|
-
stream.emit("session cleared");
|
|
969
|
+
await stream.emit("session cleared");
|
|
902
970
|
}
|
|
903
971
|
// For "response" commands: pipeline already emitted the response via onTextChunk
|
|
904
972
|
await callbacks.flush();
|
|
@@ -906,7 +974,7 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
|
|
|
906
974
|
}
|
|
907
975
|
/* v8 ignore start -- failover display: tested via pipeline integration tests @preserve */
|
|
908
976
|
if (result.failoverMessage) {
|
|
909
|
-
stream.emit(result.failoverMessage);
|
|
977
|
+
await stream.emit(result.failoverMessage);
|
|
910
978
|
}
|
|
911
979
|
else if (capturedTerminalError) {
|
|
912
980
|
callbacks.onError(capturedTerminalError, "terminal");
|
|
@@ -915,7 +983,7 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
|
|
|
915
983
|
// ── Handle gate result ────────────────────────────────────────
|
|
916
984
|
if (!result.gateResult.allowed) {
|
|
917
985
|
if ("autoReply" in result.gateResult && result.gateResult.autoReply) {
|
|
918
|
-
stream.emit(result.gateResult.autoReply);
|
|
986
|
+
await stream.emit(result.gateResult.autoReply);
|
|
919
987
|
}
|
|
920
988
|
return;
|
|
921
989
|
}
|
|
@@ -962,9 +1030,11 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
|
|
|
962
1030
|
}
|
|
963
1031
|
catch (error) {
|
|
964
1032
|
if (error instanceof session_transaction_1.SessionTurnBusyError || (error instanceof Error && error.name === "SessionTurnBusyError")) {
|
|
965
|
-
|
|
1033
|
+
reportTeamsRequest("failed", conversationId, teamsContext, { traceId, reason: "lease_busy" });
|
|
1034
|
+
await stream.emit("this conversation is busy finishing another turn; please try again");
|
|
966
1035
|
return;
|
|
967
1036
|
}
|
|
1037
|
+
reportTeamsRequest("failed", conversationId, teamsContext, { traceId, reason: "turn_failed" });
|
|
968
1038
|
throw error;
|
|
969
1039
|
}
|
|
970
1040
|
}
|
|
@@ -1153,121 +1223,182 @@ function registerBotHandlers(app, label) {
|
|
|
1153
1223
|
});
|
|
1154
1224
|
/* v8 ignore stop */
|
|
1155
1225
|
app.on("message", async (ctx) => {
|
|
1156
|
-
const {
|
|
1226
|
+
const { activity, api, signin } = ctx;
|
|
1157
1227
|
const text = activity.text || "";
|
|
1158
1228
|
const convId = activity.conversation?.id || "unknown";
|
|
1159
1229
|
const turnKey = teamsTurnKey(convId);
|
|
1160
1230
|
const userId = activity.from?.id || "";
|
|
1161
1231
|
const channelId = activity.channelId || "msteams";
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1232
|
+
const requestContext = {
|
|
1233
|
+
inboundActivityId: activity.id, tenantId: activity.conversation?.tenantId,
|
|
1234
|
+
botApi: app.id && api ? { id: app.id, conversations: api.conversations } : undefined,
|
|
1235
|
+
};
|
|
1236
|
+
const sendText = async (text) => {
|
|
1237
|
+
try {
|
|
1238
|
+
const receipt = await ctx.send({ type: "message", text, replyToId: activity.id, entities: aiLabelEntities(), channelData: { feedbackLoopEnabled: true } });
|
|
1239
|
+
const messageId = receipt && typeof receipt === "object" && "id" in receipt && typeof receipt.id === "string" ? receipt.id : undefined;
|
|
1240
|
+
reportTeamsRequest(messageId ? "replied" : "reply_emitted", convId, requestContext, { delivery: messageId ? "accepted" : "unconfirmed", ...(messageId ? { outboundActivityId: messageId } : {}) });
|
|
1241
|
+
}
|
|
1242
|
+
catch (error) {
|
|
1243
|
+
reportTeamsRequest("failed", convId, requestContext, { reason: "send_unknown" });
|
|
1244
|
+
throw error;
|
|
1245
|
+
}
|
|
1246
|
+
};
|
|
1247
|
+
// HttpStream queues void emits and retries unknown HTTP failures internally.
|
|
1248
|
+
// Use the SDK's single-attempt, awaitable send instead of that hidden retry loop.
|
|
1249
|
+
const stream = {
|
|
1250
|
+
emit: value => sendText(typeof value === "string" ? value : value.text),
|
|
1251
|
+
update: () => ctx.send({ type: "typing" }),
|
|
1252
|
+
close: async () => undefined,
|
|
1253
|
+
};
|
|
1254
|
+
(0, runtime_1.emitNervesEvent)({ level: "info", event: "channel.message_received", component: "channels", message: `[${label}] incoming teams message`, meta: { stage: "received", inboundActivityId: activity.id, botId: app.id, tenantId: requestContext.tenantId, userId: userId.slice(0, 12), conversationId: convId } });
|
|
1255
|
+
let work;
|
|
1256
|
+
await _turnCoordinator.withTurnLock(teamsAdmissionKey(convId), async () => {
|
|
1257
|
+
try {
|
|
1258
|
+
if (process.env.OURO_TEAMS_DEVTOOLS !== "1" && [
|
|
1259
|
+
app.id, activity.id, activity.channelId, activity.conversation?.id,
|
|
1260
|
+
activity.conversation?.tenantId, activity.from?.aadObjectId || activity.from?.id,
|
|
1261
|
+
].some(value => typeof value !== "string" || !value.trim())) {
|
|
1262
|
+
throw new session_transaction_1.SessionTransactionError("Teams activity identity is incomplete");
|
|
1171
1263
|
}
|
|
1172
|
-
if (
|
|
1173
|
-
const
|
|
1174
|
-
const
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
provider: commandProvider,
|
|
1178
|
-
externalId: commandExternalId,
|
|
1264
|
+
if (activity.id) {
|
|
1265
|
+
const store = getFriendStore();
|
|
1266
|
+
const identity = await new friends_1.FriendResolver(store, {
|
|
1267
|
+
provider: activity.from?.aadObjectId ? "aad" : "teams-conversation",
|
|
1268
|
+
externalId: activity.from?.aadObjectId || convId,
|
|
1179
1269
|
tenantId: activity.conversation?.tenantId,
|
|
1180
1270
|
displayName: activity.from?.name || "Unknown",
|
|
1181
1271
|
channel: "teams",
|
|
1272
|
+
}).resolve();
|
|
1273
|
+
const admission = (0, session_transaction_1.admitSessionActivity)((0, config_2.sessionPath)(identity.friend.id, "teams", convId), {
|
|
1274
|
+
namespace: JSON.stringify(["teams.message", channelId, app.id?.toLowerCase() ?? null,
|
|
1275
|
+
activity.conversation?.tenantId?.toLowerCase() ?? null,
|
|
1276
|
+
activity.from?.aadObjectId?.toLowerCase() || userId, convId]),
|
|
1277
|
+
activityId: activity.id,
|
|
1278
|
+
payloadDigest: (0, node_crypto_1.createHash)("sha256").update(JSON.stringify([text, activity.replyToId ?? null])).digest("hex"),
|
|
1182
1279
|
});
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
if (_turnCoordinator.isTurnActive(turnKey)) {
|
|
1187
|
-
_turnCoordinator.enqueueFollowUp(turnKey, {
|
|
1188
|
-
conversationId: convId,
|
|
1189
|
-
text,
|
|
1190
|
-
receivedAt: Date.now(),
|
|
1191
|
-
effect: "clear_and_supersede",
|
|
1192
|
-
});
|
|
1280
|
+
if (admission.state === "duplicate") {
|
|
1281
|
+
reportTeamsRequest("duplicate_held", convId, requestContext, { receiptId: admission.receiptId, outcome: "not_replayed" });
|
|
1282
|
+
return;
|
|
1193
1283
|
}
|
|
1194
|
-
return;
|
|
1195
1284
|
}
|
|
1285
|
+
reportTeamsRequest("admitted", convId, requestContext);
|
|
1196
1286
|
}
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1287
|
+
catch (error) {
|
|
1288
|
+
reportTeamsRequest("failed", convId, requestContext, { reason: "activity_admission_failed" });
|
|
1289
|
+
(0, runtime_1.emitNervesEvent)({
|
|
1290
|
+
level: "error", component: "channels", event: "channel.activity_admission_error",
|
|
1291
|
+
message: "Teams activity was not safely admitted; no new turn or steering was started",
|
|
1292
|
+
meta: { conversationId: convId, inboundActivityId: activity.id, reason: "activity_admission_failed" },
|
|
1293
|
+
});
|
|
1294
|
+
throw error;
|
|
1295
|
+
}
|
|
1296
|
+
const commandRegistry = createTeamsCommandRegistry();
|
|
1297
|
+
const parsedSlashCommand = (0, commands_1.parseSlashCommand)(text);
|
|
1298
|
+
if (parsedSlashCommand) {
|
|
1299
|
+
const dispatchResult = commandRegistry.dispatch(parsedSlashCommand.command, { channel: "teams" });
|
|
1300
|
+
if (dispatchResult.handled && dispatchResult.result) {
|
|
1301
|
+
if (dispatchResult.result.action === "response") {
|
|
1302
|
+
await stream.emit(dispatchResult.result.message || "");
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
if (dispatchResult.result.action === "new") {
|
|
1306
|
+
const commandStore = getFriendStore();
|
|
1307
|
+
const commandProvider = activity.from?.aadObjectId ? "aad" : "teams-conversation";
|
|
1308
|
+
const commandExternalId = activity.from?.aadObjectId || convId;
|
|
1309
|
+
const commandResolver = new friends_1.FriendResolver(commandStore, {
|
|
1310
|
+
provider: commandProvider,
|
|
1311
|
+
externalId: commandExternalId,
|
|
1312
|
+
tenantId: activity.conversation?.tenantId,
|
|
1313
|
+
displayName: activity.from?.name || "Unknown",
|
|
1314
|
+
channel: "teams",
|
|
1315
|
+
});
|
|
1316
|
+
const commandContext = await commandResolver.resolve();
|
|
1317
|
+
(0, context_1.deleteSession)((0, config_2.sessionPath)(commandContext.friend.id, "teams", convId));
|
|
1318
|
+
await stream.emit("session cleared");
|
|
1319
|
+
if (_turnCoordinator.isTurnActive(turnKey)) {
|
|
1320
|
+
_turnCoordinator.enqueueFollowUp(turnKey, {
|
|
1321
|
+
conversationId: convId,
|
|
1322
|
+
text,
|
|
1323
|
+
receivedAt: Date.now(),
|
|
1324
|
+
effect: "clear_and_supersede",
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1214
1329
|
}
|
|
1215
|
-
|
|
1330
|
+
}
|
|
1331
|
+
work = coordinateTeamsTurn(convId, text, {
|
|
1332
|
+
aadObjectId: activity.from?.aadObjectId, userId, tenantId: activity.conversation?.tenantId,
|
|
1333
|
+
replyToId: activity.replyToId,
|
|
1334
|
+
}, async () => {
|
|
1216
1335
|
try {
|
|
1217
|
-
|
|
1218
|
-
|
|
1336
|
+
// Resolve OAuth connection names for this user's tenant (supports per-tenant overrides).
|
|
1337
|
+
const tenantId = activity.conversation?.tenantId;
|
|
1338
|
+
const tenantOAuth = (0, config_1.resolveOAuthForTenant)(tenantId);
|
|
1339
|
+
// Fetch tokens for both OAuth connections independently.
|
|
1340
|
+
// Failures are silently caught -- the tool handler will request signin if needed.
|
|
1341
|
+
let graphToken;
|
|
1342
|
+
let adoToken;
|
|
1343
|
+
let githubToken;
|
|
1344
|
+
try {
|
|
1345
|
+
const graphRes = await api.users.token.get({ userId, connectionName: tenantOAuth.graphConnectionName, channelId });
|
|
1346
|
+
graphToken = graphRes?.token;
|
|
1347
|
+
}
|
|
1348
|
+
catch { /* no token yet — tool handler will trigger signin */ }
|
|
1349
|
+
try {
|
|
1350
|
+
const adoRes = await api.users.token.get({ userId, connectionName: tenantOAuth.adoConnectionName, channelId });
|
|
1351
|
+
adoToken = adoRes?.token;
|
|
1352
|
+
}
|
|
1353
|
+
catch { /* no token yet — tool handler will trigger signin */ }
|
|
1354
|
+
try {
|
|
1355
|
+
const githubRes = await api.users.token.get({ userId, connectionName: tenantOAuth.githubConnectionName, channelId });
|
|
1356
|
+
githubToken = githubRes?.token;
|
|
1357
|
+
}
|
|
1358
|
+
catch { /* no token yet — tool handler will trigger signin */ }
|
|
1359
|
+
(0, runtime_1.emitNervesEvent)({ level: "info", event: "channel.token_status", component: "channels", message: "oauth token availability", meta: { graph: !!graphToken, ado: !!adoToken, github: !!githubToken, tenantId } });
|
|
1360
|
+
const teamsContext = {
|
|
1361
|
+
inboundActivityId: activity.id,
|
|
1362
|
+
replyToId: activity.replyToId,
|
|
1363
|
+
graphToken,
|
|
1364
|
+
adoToken,
|
|
1365
|
+
githubToken,
|
|
1366
|
+
signin: async (cn) => {
|
|
1367
|
+
try {
|
|
1368
|
+
const result = await signin({ connectionName: cn });
|
|
1369
|
+
(0, runtime_1.emitNervesEvent)({ level: "info", event: "channel.signin_result", component: "channels", message: `signin(${cn}): ${result ? "token received" : "no token"}`, meta: { connectionName: cn, hasToken: !!result } });
|
|
1370
|
+
return result;
|
|
1371
|
+
}
|
|
1372
|
+
catch (e) {
|
|
1373
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1374
|
+
(0, runtime_1.emitNervesEvent)({ level: "error", event: "channel.signin_error", component: "channels", message: `signin(${cn}) failed`, meta: { connectionName: cn, reason: msg.slice(0, 100) } });
|
|
1375
|
+
return undefined;
|
|
1376
|
+
}
|
|
1377
|
+
},
|
|
1378
|
+
aadObjectId: activity.from?.aadObjectId,
|
|
1379
|
+
tenantId: activity.conversation?.tenantId,
|
|
1380
|
+
displayName: activity.from?.name,
|
|
1381
|
+
graphConnectionName: tenantOAuth.graphConnectionName,
|
|
1382
|
+
adoConnectionName: tenantOAuth.adoConnectionName,
|
|
1383
|
+
githubConnectionName: tenantOAuth.githubConnectionName,
|
|
1384
|
+
/* v8 ignore next -- bot API availability branch; requires live SDK context @preserve */
|
|
1385
|
+
botApi: app.id && api ? { id: app.id, conversations: api.conversations } : undefined,
|
|
1386
|
+
teamsSendImage: (0, teams_image_1.createTeamsImageSender)((message) => ctx.send(message), activity.id, aiLabelEntities()),
|
|
1387
|
+
};
|
|
1388
|
+
await handleTeamsMessage(text, stream, convId, teamsContext, sendText);
|
|
1219
1389
|
}
|
|
1220
|
-
catch {
|
|
1221
|
-
|
|
1222
|
-
const
|
|
1223
|
-
|
|
1390
|
+
catch (err) {
|
|
1391
|
+
reportTeamsRequest("failed", convId, requestContext, { reason: "handler_failed" });
|
|
1392
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1393
|
+
(0, runtime_1.emitNervesEvent)({ level: "error", event: "channel.handler_error", component: "channels", message: msg.slice(0, 200), meta: { conversationId: convId, inboundActivityId: activity.id, botId: app.id } });
|
|
1224
1394
|
}
|
|
1225
|
-
|
|
1226
|
-
(0, runtime_1.emitNervesEvent)({ level: "info", event: "channel.token_status", component: "channels", message: "oauth token availability", meta: { graph: !!graphToken, ado: !!adoToken, github: !!githubToken, tenantId } });
|
|
1227
|
-
const teamsContext = {
|
|
1228
|
-
replyToId: activity.replyToId,
|
|
1229
|
-
graphToken,
|
|
1230
|
-
adoToken,
|
|
1231
|
-
githubToken,
|
|
1232
|
-
signin: async (cn) => {
|
|
1233
|
-
try {
|
|
1234
|
-
const result = await signin({ connectionName: cn });
|
|
1235
|
-
(0, runtime_1.emitNervesEvent)({ level: "info", event: "channel.signin_result", component: "channels", message: `signin(${cn}): ${result ? "token received" : "no token"}`, meta: { connectionName: cn, hasToken: !!result } });
|
|
1236
|
-
return result;
|
|
1237
|
-
}
|
|
1238
|
-
catch (e) {
|
|
1239
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
1240
|
-
(0, runtime_1.emitNervesEvent)({ level: "error", event: "channel.signin_error", component: "channels", message: `signin(${cn}) failed`, meta: { connectionName: cn, reason: msg.slice(0, 100) } });
|
|
1241
|
-
return undefined;
|
|
1242
|
-
}
|
|
1243
|
-
},
|
|
1244
|
-
aadObjectId: activity.from?.aadObjectId,
|
|
1245
|
-
tenantId: activity.conversation?.tenantId,
|
|
1246
|
-
displayName: activity.from?.name,
|
|
1247
|
-
graphConnectionName: tenantOAuth.graphConnectionName,
|
|
1248
|
-
adoConnectionName: tenantOAuth.adoConnectionName,
|
|
1249
|
-
githubConnectionName: tenantOAuth.githubConnectionName,
|
|
1250
|
-
/* v8 ignore next -- bot API availability branch; requires live SDK context @preserve */
|
|
1251
|
-
botApi: app.id && api ? { id: app.id, conversations: api.conversations } : undefined,
|
|
1252
|
-
teamsSendImage: (0, teams_image_1.createTeamsImageSender)((message) => ctx.send(message), activity.id, aiLabelEntities()),
|
|
1253
|
-
};
|
|
1254
|
-
/* v8 ignore next 5 -- bot-framework integration callback; tested via handleTeamsMessage sendMessage path @preserve */
|
|
1255
|
-
const ctxSend = async (t) => {
|
|
1256
|
-
// Use send with replyToId (not reply, which adds a blockquote).
|
|
1257
|
-
// replyToId anchors the message after the user's message in Copilot Chat.
|
|
1258
|
-
await ctx.send({ type: "message", text: t, replyToId: activity.id, entities: aiLabelEntities(), channelData: { feedbackLoopEnabled: true } });
|
|
1259
|
-
};
|
|
1260
|
-
await handleTeamsMessage(text, stream, convId, teamsContext, ctxSend);
|
|
1261
|
-
}
|
|
1262
|
-
catch (err) {
|
|
1263
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1264
|
-
(0, runtime_1.emitNervesEvent)({ level: "error", event: "channel.handler_error", component: "channels", message: msg.slice(0, 200), meta: {} });
|
|
1265
|
-
}
|
|
1395
|
+
});
|
|
1266
1396
|
});
|
|
1397
|
+
await work;
|
|
1267
1398
|
});
|
|
1268
1399
|
app.event("error", ({ error }) => {
|
|
1269
1400
|
const msg = error instanceof Error ? error.message : String(error);
|
|
1270
|
-
(0, runtime_1.emitNervesEvent)({ level: "error", event: "channel.app_error", component: "channels", message: `[${label}] ${msg}`, meta: {} });
|
|
1401
|
+
(0, runtime_1.emitNervesEvent)({ level: "error", event: "channel.app_error", component: "channels", message: `[${label}] ${msg}`, meta: { botId: app.id, label } });
|
|
1271
1402
|
});
|
|
1272
1403
|
}
|
|
1273
1404
|
function findAadObjectId(friend) {
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.934",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@ouro.bot/cli",
|
|
9
|
-
"version": "0.1.0-alpha.
|
|
9
|
+
"version": "0.1.0-alpha.934",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|