@yeaft/webchat-agent 1.0.171 → 1.0.173
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/package.json +1 -1
- package/yeaft/engine.js +22 -1
- package/yeaft/tools/ask-user.js +7 -0
- package/yeaft/web-bridge.js +159 -57
- package/yeaft/work-center/projection.js +66 -2
- package/yeaft/work-center/runner.js +1 -1
- package/yeaft/work-center/workflow.js +14 -8
package/package.json
CHANGED
package/yeaft/engine.js
CHANGED
|
@@ -524,6 +524,9 @@ export class Engine {
|
|
|
524
524
|
/** @type {Array<{content:string|Array, preview:string}>} */
|
|
525
525
|
#pendingUserMessages = [];
|
|
526
526
|
|
|
527
|
+
/** True after the bridge queued input in its external per-thread buffer. */
|
|
528
|
+
#externalUserWakePending = false;
|
|
529
|
+
|
|
527
530
|
/**
|
|
528
531
|
* Tasks (background bash, sub-agent spawns) that were launched DURING
|
|
529
532
|
* the currently-running query() and have NOT terminated yet. The query
|
|
@@ -1654,6 +1657,7 @@ export class Engine {
|
|
|
1654
1657
|
|
|
1655
1658
|
#drainPendingUserMessages(drainPendingUserMessages) {
|
|
1656
1659
|
const pending = [];
|
|
1660
|
+
this.#externalUserWakePending = false;
|
|
1657
1661
|
if (typeof drainPendingUserMessages === 'function') {
|
|
1658
1662
|
try {
|
|
1659
1663
|
const drained = drainPendingUserMessages();
|
|
@@ -1809,6 +1813,7 @@ export class Engine {
|
|
|
1809
1813
|
this.#abortReason = null;
|
|
1810
1814
|
this.#currentThreadId = MAIN_THREAD_ID;
|
|
1811
1815
|
this.#pendingUserMessages.length = 0;
|
|
1816
|
+
this.#externalUserWakePending = false;
|
|
1812
1817
|
this.#asyncTaskToolMeta.clear();
|
|
1813
1818
|
this.#pendingTaskResultMessages.length = 0;
|
|
1814
1819
|
this.#pendingTaskResultUpdates.length = 0;
|
|
@@ -2955,7 +2960,8 @@ export class Engine {
|
|
|
2955
2960
|
while (!signal?.aborted
|
|
2956
2961
|
&& this.#pendingTaskResultMessages.length === 0
|
|
2957
2962
|
&& this.#pendingTaskResultUpdates.length === 0
|
|
2958
|
-
&& this.#pendingUserMessages.length === 0
|
|
2963
|
+
&& this.#pendingUserMessages.length === 0
|
|
2964
|
+
&& !this.#externalUserWakePending) {
|
|
2959
2965
|
if (this.#pendingAsyncTaskIds.size === 0) break;
|
|
2960
2966
|
await this.#waitForAsyncWake(signal);
|
|
2961
2967
|
}
|
|
@@ -3680,6 +3686,20 @@ export class Engine {
|
|
|
3680
3686
|
return true;
|
|
3681
3687
|
}
|
|
3682
3688
|
|
|
3689
|
+
/**
|
|
3690
|
+
* Wake a query whose external pending-message hook has received input.
|
|
3691
|
+
* The bridge keeps the full message (attachments and provenance included)
|
|
3692
|
+
* in its thread queue; this method only releases an async-task wait so the
|
|
3693
|
+
* normal drain callback can consume it at the next safe adapter boundary.
|
|
3694
|
+
* @returns {boolean}
|
|
3695
|
+
*/
|
|
3696
|
+
wakeForPendingUserMessage() {
|
|
3697
|
+
if (!this.isRunning) return false;
|
|
3698
|
+
this.#externalUserWakePending = true;
|
|
3699
|
+
this.#wakeAsyncTaskWaiters();
|
|
3700
|
+
return true;
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3683
3703
|
/**
|
|
3684
3704
|
* Install the coordinator that lets an external dispatcher (web-bridge)
|
|
3685
3705
|
* route a background task's terminal event back to THIS engine while
|
|
@@ -3851,6 +3871,7 @@ export class Engine {
|
|
|
3851
3871
|
if (this.#pendingTaskResultMessages.length > 0) return resolve();
|
|
3852
3872
|
if (this.#pendingTaskResultUpdates.length > 0) return resolve();
|
|
3853
3873
|
if (this.#pendingUserMessages.length > 0) return resolve();
|
|
3874
|
+
if (this.#externalUserWakePending) return resolve();
|
|
3854
3875
|
if (signal?.aborted) return resolve();
|
|
3855
3876
|
this.#asyncTaskWaiters.push(resolve);
|
|
3856
3877
|
if (signal && typeof signal.addEventListener === 'function') {
|
package/yeaft/tools/ask-user.js
CHANGED
|
@@ -66,6 +66,13 @@ Guidelines:
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
const answers = await ctx.askUser({ question, options });
|
|
69
|
+
if (answers?.__yeaftTimedOut) {
|
|
70
|
+
return JSON.stringify({
|
|
71
|
+
question,
|
|
72
|
+
timedOut: true,
|
|
73
|
+
message: 'The user did not answer before the request expired. Continue without this input.',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
69
76
|
return JSON.stringify({ question, answers });
|
|
70
77
|
},
|
|
71
78
|
});
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -80,7 +80,27 @@ const YEAFT_SKILL_COMMAND_PREFIX = 'yeaft-skills:';
|
|
|
80
80
|
const PROJECT_SKILL_TIERS = new Set(['project', 'project-claude', 'project-codex']);
|
|
81
81
|
const BASE_RUNTIME_KEY = '__base__';
|
|
82
82
|
|
|
83
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Live AskUser requests. They are Session-scoped runtime state rather than a
|
|
85
|
+
* turn lock: every connected client may answer the same request, while the
|
|
86
|
+
* first valid answer wins. Pending requests are replayed when another device
|
|
87
|
+
* loads the Session.
|
|
88
|
+
* @type {Map<string, {
|
|
89
|
+
* resolve:Function,
|
|
90
|
+
* reject?:Function,
|
|
91
|
+
* sessionId:string,
|
|
92
|
+
* vpId:string,
|
|
93
|
+
* threadId:string,
|
|
94
|
+
* turnId:string,
|
|
95
|
+
* question:string,
|
|
96
|
+
* options:Array<string>,
|
|
97
|
+
* createdAt:number,
|
|
98
|
+
* expiresAt:number,
|
|
99
|
+
* timer?:NodeJS.Timeout|null,
|
|
100
|
+
* resumeQueryTimer?:Function,
|
|
101
|
+
* signal?:AbortSignal,
|
|
102
|
+
* onAbort?:Function,
|
|
103
|
+
* }>} */
|
|
84
104
|
const pendingUserPrompts = new Map();
|
|
85
105
|
|
|
86
106
|
/** @type {import('./session.js').Session | null} */
|
|
@@ -212,6 +232,7 @@ function scheduleYeaftLoadHistoryMetadataReplay(sessionId) {
|
|
|
212
232
|
yeaftDir: ctx.CONFIG?.yeaftDir || null,
|
|
213
233
|
tasks: replaySession.taskManager ? replaySession.taskManager.listActiveTasks() : [],
|
|
214
234
|
}, { sessionId });
|
|
235
|
+
if (sessionId) replayPendingUserPrompts(sessionId);
|
|
215
236
|
sendSessionSnapshotBroadcast();
|
|
216
237
|
if (sessionId && session === replaySession) {
|
|
217
238
|
sendDreamSnapshotForSession(sessionId, { trigger: 'load_history' }).catch(() => null);
|
|
@@ -796,6 +817,8 @@ export function broadcastLanguageChange(language) {
|
|
|
796
817
|
/** Query timeout in ms — abort if LLM doesn't respond within this window */
|
|
797
818
|
const QUERY_TIMEOUT_MS = 120_000;
|
|
798
819
|
const HIGH_REASONING_QUERY_TIMEOUT_MS = 300_000;
|
|
820
|
+
/** AskUser is human-paced but must never pin a VP turn forever. */
|
|
821
|
+
const ASK_USER_TIMEOUT_MS = 10 * 60_000;
|
|
799
822
|
|
|
800
823
|
function isHighReasoningEffort(effort) {
|
|
801
824
|
const value = typeof effort === 'string' ? effort.trim().toLowerCase() : '';
|
|
@@ -1378,6 +1401,12 @@ export function __testGetVpThreads(sessionId, vpId) {
|
|
|
1378
1401
|
}));
|
|
1379
1402
|
}
|
|
1380
1403
|
|
|
1404
|
+
/** Test-only: attach an Engine-like wake target to a seeded VP thread. */
|
|
1405
|
+
export function __testSetVpThreadEngine({ sessionId, vpId, threadId, engine }) {
|
|
1406
|
+
const thread = getOrCreateVpThread({ sessionId, vpId, threadId });
|
|
1407
|
+
thread.engine = engine || null;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1381
1410
|
/** Test-only: seed a VP thread without starting its engine driver. */
|
|
1382
1411
|
export function __testSeedVpThread({ sessionId, vpId, threadId, title = 'test thread', status = 'queued' }) {
|
|
1383
1412
|
const thread = getOrCreateVpThread({ sessionId, vpId, threadId, title });
|
|
@@ -1725,31 +1754,43 @@ async function routeEnvelopeToVpThread(sessionId, vpId, envelope) {
|
|
|
1725
1754
|
senderVpId: isInternalAppend ? (envelope?.msg?.meta?.senderVpId || envelope?.msg?.from || null) : null,
|
|
1726
1755
|
sourceThreadId: isInternalAppend ? visibleInboundThreadId(envelope, thread.threadId) : null,
|
|
1727
1756
|
});
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
sendSessionEvent({
|
|
1743
|
-
type: 'vp_thread_user_appended',
|
|
1757
|
+
// A thread parked on a background task has no adapter activity to poll this
|
|
1758
|
+
// queue. Wake its Engine explicitly so the new user message is consumed
|
|
1759
|
+
// immediately while the task remains tracked in TaskManager. If the Engine
|
|
1760
|
+
// is not actually running, this was a stale classifier hit; remove the
|
|
1761
|
+
// append and fall through to a normal queued turn instead of orphaning it.
|
|
1762
|
+
let appendedToRunningThread = false;
|
|
1763
|
+
try { appendedToRunningThread = thread.engine?.wakeForPendingUserMessage?.() === true; } catch { /* best-effort wake */ }
|
|
1764
|
+
if (!appendedToRunningThread) {
|
|
1765
|
+
thread.pendingQueries.pop();
|
|
1766
|
+
related = false;
|
|
1767
|
+
} else {
|
|
1768
|
+
persistInboundMessageOnceByMsgId({
|
|
1769
|
+
msgId: envelope?.msg?.id,
|
|
1770
|
+
text,
|
|
1744
1771
|
sessionId,
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1772
|
+
threadId: visibleInboundThreadId(envelope, thread.threadId),
|
|
1773
|
+
role: isInternalAppend ? 'assistant' : 'user',
|
|
1774
|
+
speakerVpId: envelope?.msg?.meta?.senderVpId || envelope?.msg?.from || null,
|
|
1775
|
+
attachments: Array.isArray(envelope?.msg?.meta?.attachments) ? envelope.msg.meta.attachments : [],
|
|
1776
|
+
internal: isInternalAppend,
|
|
1777
|
+
ts: envelope?.msg?.ts || null,
|
|
1778
|
+
clientMessageId: envelope?.msg?.meta?.clientMessageId || null,
|
|
1779
|
+
});
|
|
1780
|
+
thread.updatedAt = Date.now();
|
|
1781
|
+
try {
|
|
1782
|
+
sendSessionEvent({
|
|
1783
|
+
type: 'vp_thread_user_appended',
|
|
1784
|
+
sessionId,
|
|
1785
|
+
vpId,
|
|
1786
|
+
threadId: thread.threadId,
|
|
1787
|
+
title: thread.title,
|
|
1788
|
+
turnId,
|
|
1789
|
+
ts: Date.now(),
|
|
1790
|
+
}, { sessionId, vpId, threadId: thread.threadId, turnId });
|
|
1791
|
+
} catch { /* never crash WS pipeline */ }
|
|
1792
|
+
return thread.threadId;
|
|
1793
|
+
}
|
|
1753
1794
|
}
|
|
1754
1795
|
|
|
1755
1796
|
const key = threadKey(sessionId, vpId, thread.threadId);
|
|
@@ -2335,6 +2376,60 @@ function sendSessionEvent(event, { sessionId, chatId, vpId, turnId, threadId, pe
|
|
|
2335
2376
|
});
|
|
2336
2377
|
}
|
|
2337
2378
|
|
|
2379
|
+
function pendingUserPromptEvent(requestId, pending, extra = {}) {
|
|
2380
|
+
return {
|
|
2381
|
+
type: 'ask_user_question',
|
|
2382
|
+
requestId,
|
|
2383
|
+
questions: [{
|
|
2384
|
+
question: pending.question,
|
|
2385
|
+
options: pending.options.map(label => ({ label, description: '' })),
|
|
2386
|
+
multiSelect: false,
|
|
2387
|
+
}],
|
|
2388
|
+
createdAt: pending.createdAt,
|
|
2389
|
+
expiresAt: pending.expiresAt,
|
|
2390
|
+
...extra,
|
|
2391
|
+
};
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
function sendPendingUserPrompt(requestId, pending, extra = {}) {
|
|
2395
|
+
sendSessionEvent(pendingUserPromptEvent(requestId, pending, extra), {
|
|
2396
|
+
sessionId: pending.sessionId,
|
|
2397
|
+
vpId: pending.vpId,
|
|
2398
|
+
threadId: pending.threadId,
|
|
2399
|
+
turnId: pending.turnId,
|
|
2400
|
+
});
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
function replayPendingUserPrompts(sessionId) {
|
|
2404
|
+
const now = Date.now();
|
|
2405
|
+
for (const [requestId, pending] of pendingUserPrompts) {
|
|
2406
|
+
if (pending.sessionId !== sessionId || pending.expiresAt <= now) continue;
|
|
2407
|
+
sendPendingUserPrompt(requestId, pending, { replay: true });
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
function settlePendingUserPrompt(requestId, pending, { answers = null, timedOut = false } = {}) {
|
|
2412
|
+
if (pendingUserPrompts.get(requestId) !== pending) return false;
|
|
2413
|
+
pendingUserPrompts.delete(requestId);
|
|
2414
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
2415
|
+
if (pending.signal && pending.onAbort) {
|
|
2416
|
+
try { pending.signal.removeEventListener('abort', pending.onAbort); } catch { /* ignore */ }
|
|
2417
|
+
}
|
|
2418
|
+
try { pending.resumeQueryTimer?.(); } catch { /* best-effort */ }
|
|
2419
|
+
sendSessionEvent({
|
|
2420
|
+
type: timedOut ? 'ask_user_expired' : 'ask_user_answered',
|
|
2421
|
+
requestId,
|
|
2422
|
+
...(timedOut ? { expiredAt: Date.now() } : { answers: answers || {} }),
|
|
2423
|
+
}, {
|
|
2424
|
+
sessionId: pending.sessionId,
|
|
2425
|
+
vpId: pending.vpId,
|
|
2426
|
+
threadId: pending.threadId,
|
|
2427
|
+
turnId: pending.turnId,
|
|
2428
|
+
});
|
|
2429
|
+
pending.resolve(timedOut ? { __yeaftTimedOut: true } : (answers || {}));
|
|
2430
|
+
return true;
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2338
2433
|
function configuredVpPaths() {
|
|
2339
2434
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
2340
2435
|
if (typeof yeaftDir !== 'string' || !yeaftDir.trim()) return {};
|
|
@@ -4560,40 +4655,44 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
|
|
|
4560
4655
|
askUser: ({ question, options }) => new Promise((resolve, reject) => {
|
|
4561
4656
|
const requestId = `ask_${randomUUID()}`;
|
|
4562
4657
|
const signal = vpAbort.signal;
|
|
4563
|
-
|
|
4564
|
-
//
|
|
4658
|
+
const createdAt = Date.now();
|
|
4659
|
+
// Human think time is not engine silence. Pause the query watchdog,
|
|
4660
|
+
// but keep a separate bounded AskUser lifetime so an abandoned card
|
|
4661
|
+
// cannot pin the VP forever.
|
|
4565
4662
|
if (queryTimer) {
|
|
4566
4663
|
clearTimeout(queryTimer);
|
|
4567
4664
|
queryTimer = null;
|
|
4568
4665
|
}
|
|
4569
|
-
const
|
|
4570
|
-
|
|
4571
|
-
reject
|
|
4572
|
-
};
|
|
4573
|
-
pendingUserPrompts.set(requestId, {
|
|
4574
|
-
resolve: answers => {
|
|
4575
|
-
resetQueryTimer();
|
|
4576
|
-
resolve(answers);
|
|
4577
|
-
},
|
|
4666
|
+
const pending = {
|
|
4667
|
+
resolve,
|
|
4668
|
+
reject,
|
|
4578
4669
|
sessionId,
|
|
4579
4670
|
vpId,
|
|
4580
4671
|
threadId,
|
|
4581
4672
|
turnId,
|
|
4673
|
+
question,
|
|
4674
|
+
options: Array.isArray(options) ? options.filter(label => typeof label === 'string') : [],
|
|
4675
|
+
createdAt,
|
|
4676
|
+
expiresAt: createdAt + ASK_USER_TIMEOUT_MS,
|
|
4677
|
+
timer: null,
|
|
4678
|
+
resumeQueryTimer: resetQueryTimer,
|
|
4582
4679
|
signal,
|
|
4583
|
-
onAbort,
|
|
4584
|
-
}
|
|
4680
|
+
onAbort: null,
|
|
4681
|
+
};
|
|
4682
|
+
const onAbort = () => {
|
|
4683
|
+
if (pendingUserPrompts.get(requestId) !== pending) return;
|
|
4684
|
+
pendingUserPrompts.delete(requestId);
|
|
4685
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
4686
|
+
reject(new Error('aborted'));
|
|
4687
|
+
};
|
|
4688
|
+
pending.onAbort = onAbort;
|
|
4689
|
+
pending.timer = setTimeout(() => {
|
|
4690
|
+
settlePendingUserPrompt(requestId, pending, { timedOut: true });
|
|
4691
|
+
}, ASK_USER_TIMEOUT_MS);
|
|
4692
|
+
if (typeof pending.timer.unref === 'function') pending.timer.unref();
|
|
4693
|
+
pendingUserPrompts.set(requestId, pending);
|
|
4585
4694
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
4586
|
-
|
|
4587
|
-
type: 'ask_user_question',
|
|
4588
|
-
requestId,
|
|
4589
|
-
questions: [{
|
|
4590
|
-
question,
|
|
4591
|
-
options: Array.isArray(options)
|
|
4592
|
-
? options.map(label => ({ label, description: '' }))
|
|
4593
|
-
: [],
|
|
4594
|
-
multiSelect: false,
|
|
4595
|
-
}],
|
|
4596
|
-
}, envelope);
|
|
4695
|
+
sendPendingUserPrompt(requestId, pending);
|
|
4597
4696
|
}),
|
|
4598
4697
|
threadId,
|
|
4599
4698
|
vpTurnId: turnId,
|
|
@@ -5639,12 +5738,7 @@ export function handleYeaftAskUserAnswer(msg) {
|
|
|
5639
5738
|
if (msg.turnId && msg.turnId !== pending.turnId) return false;
|
|
5640
5739
|
if (msg.threadId && msg.threadId !== pending.threadId) return false;
|
|
5641
5740
|
|
|
5642
|
-
|
|
5643
|
-
if (pending.signal && pending.onAbort) {
|
|
5644
|
-
try { pending.signal.removeEventListener('abort', pending.onAbort); } catch { /* ignore */ }
|
|
5645
|
-
}
|
|
5646
|
-
pending.resolve(msg.answers || {});
|
|
5647
|
-
return true;
|
|
5741
|
+
return settlePendingUserPrompt(requestId, pending, { answers: msg.answers || {} });
|
|
5648
5742
|
}
|
|
5649
5743
|
|
|
5650
5744
|
export async function handleYeaftSessionSend(msg) {
|
|
@@ -6543,13 +6637,21 @@ export const __testHooks = {
|
|
|
6543
6637
|
vpAborts.clear();
|
|
6544
6638
|
vpInboxes.clear();
|
|
6545
6639
|
},
|
|
6546
|
-
seedPendingUserPrompt({ requestId = 'ask-test', sessionId = 'session-test', vpId = 'vp-test', threadId = 'main', turnId = 'turn-test' } = {}) {
|
|
6640
|
+
seedPendingUserPrompt({ requestId = 'ask-test', sessionId = 'session-test', vpId = 'vp-test', threadId = 'main', turnId = 'turn-test', question = 'Continue?', options = [], createdAt = Date.now(), expiresAt = Date.now() + ASK_USER_TIMEOUT_MS } = {}) {
|
|
6547
6641
|
let resolved;
|
|
6548
6642
|
const promise = new Promise(resolve => { resolved = resolve; });
|
|
6549
|
-
pendingUserPrompts.set(requestId, { resolve: resolved, sessionId, vpId, threadId, turnId });
|
|
6643
|
+
pendingUserPrompts.set(requestId, { resolve: resolved, sessionId, vpId, threadId, turnId, question, options, createdAt, expiresAt, timer: null });
|
|
6550
6644
|
return promise;
|
|
6551
6645
|
},
|
|
6646
|
+
replayPendingUserPrompts,
|
|
6647
|
+
settlePendingUserPromptForTest(requestId, opts = {}) {
|
|
6648
|
+
const pending = pendingUserPrompts.get(requestId);
|
|
6649
|
+
return pending ? settlePendingUserPrompt(requestId, pending, opts) : false;
|
|
6650
|
+
},
|
|
6552
6651
|
resetPendingUserPrompts() {
|
|
6652
|
+
for (const pending of pendingUserPrompts.values()) {
|
|
6653
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
6654
|
+
}
|
|
6553
6655
|
pendingUserPrompts.clear();
|
|
6554
6656
|
},
|
|
6555
6657
|
resetVpStatusBroker() {
|
|
@@ -36,6 +36,44 @@ function sumExecutionStats(values) {
|
|
|
36
36
|
}, emptyExecutionStats());
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
const MAX_FAILURE_REASON_LENGTH = 2_000;
|
|
40
|
+
const MAX_FAILURE_INSPECTION_LENGTH = 16_000;
|
|
41
|
+
const SAFE_FAILURE_FALLBACK = 'The Action failed. Sensitive details were omitted.';
|
|
42
|
+
const CREDENTIAL_ASSIGNMENT_PATTERN = /\b(?:[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)|api[_-]?key|access[_-]?token|authorization|password|secret|token)\s*[:=]/i;
|
|
43
|
+
const PROVIDER_TOKEN_PATTERN = /\b(?:sk-(?:proj-)?[A-Za-z0-9_-]{12,}|github_pat_[A-Za-z0-9_]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|xox[baprs]-[A-Za-z0-9-]{12,}|AKIA[A-Z0-9]{12,})\b/i;
|
|
44
|
+
const HIGH_ENTROPY_SECRET_PATTERN = /\b(?=[A-Za-z0-9_./+=-]{32,}\b)(?=[A-Za-z0-9_./+=-]*[A-Za-z])(?=[A-Za-z0-9_./+=-]*\d)[A-Za-z0-9_./+=-]+\b/;
|
|
45
|
+
const LOCAL_PATH_ASSIGNMENT_PATTERN = /\b(?:path|cwd|file|filename|directory)\s*[:=]\s*(?:\/(?!\/)|[A-Za-z]:\\|\\\\)/i;
|
|
46
|
+
const POSIX_ABSOLUTE_PATH_PATTERN = /(?:^|[\s("'`])\/(?!\/)[^\r\n]*/m;
|
|
47
|
+
const WINDOWS_ABSOLUTE_PATH_PATTERN = /(?:^|[\s("'`])(?:[A-Za-z]:\\|\\\\)[^\r\n]*/m;
|
|
48
|
+
|
|
49
|
+
function sanitizedUrl(raw) {
|
|
50
|
+
try {
|
|
51
|
+
const url = new URL(raw);
|
|
52
|
+
if (!['http:', 'https:'].includes(url.protocol)) return '[redacted URL]';
|
|
53
|
+
return `${url.protocol}//${url.host}${url.pathname}`;
|
|
54
|
+
} catch {
|
|
55
|
+
return '[redacted URL]';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sanitizeFailureReason(value) {
|
|
60
|
+
const raw = typeof value === 'string'
|
|
61
|
+
? value.trim().slice(0, MAX_FAILURE_INSPECTION_LENGTH)
|
|
62
|
+
: '';
|
|
63
|
+
if (!raw) return '';
|
|
64
|
+
const text = raw.replace(/https?:\/\/[^\s<>'"`]+/gi, sanitizedUrl);
|
|
65
|
+
if (CREDENTIAL_ASSIGNMENT_PATTERN.test(text) || PROVIDER_TOKEN_PATTERN.test(text)
|
|
66
|
+
|| HIGH_ENTROPY_SECRET_PATTERN.test(text)) return SAFE_FAILURE_FALLBACK;
|
|
67
|
+
if (LOCAL_PATH_ASSIGNMENT_PATTERN.test(text) || POSIX_ABSOLUTE_PATH_PATTERN.test(text)
|
|
68
|
+
|| WINDOWS_ABSOLUTE_PATH_PATTERN.test(text)) {
|
|
69
|
+
return SAFE_FAILURE_FALLBACK;
|
|
70
|
+
}
|
|
71
|
+
return text
|
|
72
|
+
.replace(/\b(?:Bearer|Basic)\s+[^\s,;]+/gi, '[redacted credential]')
|
|
73
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
|
|
74
|
+
.slice(0, MAX_FAILURE_REASON_LENGTH);
|
|
75
|
+
}
|
|
76
|
+
|
|
39
77
|
function actionExecution(action, runs) {
|
|
40
78
|
const matchingRuns = Array.isArray(runs)
|
|
41
79
|
? runs.filter(run => run?.actionId === action?.id)
|
|
@@ -44,6 +82,7 @@ function actionExecution(action, runs) {
|
|
|
44
82
|
return {
|
|
45
83
|
...executionStats(action),
|
|
46
84
|
response: '',
|
|
85
|
+
failureReason: '',
|
|
47
86
|
progressRevision: 0,
|
|
48
87
|
messages: [],
|
|
49
88
|
};
|
|
@@ -53,19 +92,29 @@ function actionExecution(action, runs) {
|
|
|
53
92
|
count(right.progressRevision) - count(left.progressRevision)
|
|
54
93
|
|| count(right.startedAt) - count(left.startedAt)
|
|
55
94
|
))[0];
|
|
95
|
+
const runsByEnd = [...matchingRuns]
|
|
96
|
+
.sort((left, right) => count(right.endedAt || right.startedAt) - count(left.endedAt || left.startedAt));
|
|
97
|
+
const latestRun = runsByEnd[0];
|
|
98
|
+
const showCurrentFailure = action?.status === 'failed'
|
|
99
|
+
|| ['failed', 'retryable', 'interrupted'].includes(latestRun?.status);
|
|
100
|
+
const latestFailure = showCurrentFailure
|
|
101
|
+
? runsByEnd.find(run => sanitizeFailureReason(run?.error))
|
|
102
|
+
: null;
|
|
56
103
|
const messages = [...matchingRuns]
|
|
57
104
|
.sort((left, right) => count(left.startedAt) - count(right.startedAt))
|
|
58
105
|
.map((run, index) => ({
|
|
59
106
|
id: `${action.id}:${index + 1}`,
|
|
60
107
|
status: run.status || 'running',
|
|
61
108
|
text: typeof run.response === 'string' ? run.response.trim().slice(0, 16_000) : '',
|
|
109
|
+
failureReason: sanitizeFailureReason(run.error),
|
|
62
110
|
createdAt: count(run.startedAt),
|
|
63
111
|
updatedAt: count(run.endedAt || run.startedAt),
|
|
64
112
|
}))
|
|
65
|
-
.filter(message => message.text);
|
|
113
|
+
.filter(message => message.text || message.failureReason);
|
|
66
114
|
return {
|
|
67
115
|
...stats,
|
|
68
116
|
response: typeof latest?.response === 'string' ? latest.response : '',
|
|
117
|
+
failureReason: sanitizeFailureReason(latestFailure?.error),
|
|
69
118
|
progressRevision: count(latest?.progressRevision),
|
|
70
119
|
messages,
|
|
71
120
|
};
|
|
@@ -109,6 +158,7 @@ function projectAction(action, runs) {
|
|
|
109
158
|
loopCount: execution.loopCount,
|
|
110
159
|
toolCount: execution.toolCount,
|
|
111
160
|
response: execution.response,
|
|
161
|
+
failureReason: execution.failureReason,
|
|
112
162
|
progressRevision: execution.progressRevision,
|
|
113
163
|
messages: execution.messages,
|
|
114
164
|
};
|
|
@@ -125,8 +175,11 @@ function projectActionStats(detail) {
|
|
|
125
175
|
loopCount: projected.loopCount,
|
|
126
176
|
toolCount: projected.toolCount,
|
|
127
177
|
response: projected.response,
|
|
178
|
+
failureReason: projected.failureReason,
|
|
128
179
|
progressRevision: projected.progressRevision,
|
|
129
|
-
messages: projected.messages
|
|
180
|
+
messages: projected.messages
|
|
181
|
+
.map(({ failureReason, ...message }) => message)
|
|
182
|
+
.filter(message => message.text),
|
|
130
183
|
};
|
|
131
184
|
});
|
|
132
185
|
}
|
|
@@ -139,6 +192,15 @@ function waitingReason(detail) {
|
|
|
139
192
|
))?.waitingReason || '';
|
|
140
193
|
}
|
|
141
194
|
|
|
195
|
+
function workItemFailureReason(detail) {
|
|
196
|
+
if (!['needs_attention', 'cancelled'].includes(detail?.status) || !Array.isArray(detail?.runs)) return '';
|
|
197
|
+
const ordered = [...detail.runs].sort((left, right) => (
|
|
198
|
+
count(right.endedAt || right.startedAt) - count(left.endedAt || left.startedAt)
|
|
199
|
+
));
|
|
200
|
+
const current = ordered.find(run => run?.actionId === detail.currentActionId && sanitizeFailureReason(run.error));
|
|
201
|
+
return sanitizeFailureReason(current?.error || ordered.find(run => sanitizeFailureReason(run?.error))?.error);
|
|
202
|
+
}
|
|
203
|
+
|
|
142
204
|
/**
|
|
143
205
|
* Authenticated browser detail DTO. Raw execution records stay Agent-local;
|
|
144
206
|
* the browser receives only aggregate execution stats plus the explicit user-facing response.
|
|
@@ -160,6 +222,7 @@ export function projectWorkItemDetail(detail) {
|
|
|
160
222
|
executionStats: sumExecutionStats(Array.isArray(detail.runs) ? detail.runs : []),
|
|
161
223
|
reuseMemory: detail.reuseMemory !== false,
|
|
162
224
|
waitingReason: waitingReason(detail),
|
|
225
|
+
failureReason: workItemFailureReason(detail),
|
|
163
226
|
origin: detail.origin?.sessionId ? { sessionId: detail.origin.sessionId } : null,
|
|
164
227
|
linkedSessionIds: Array.isArray(detail.linkedSessionIds) ? detail.linkedSessionIds : [],
|
|
165
228
|
attachments: projectAttachments(detail.attachments),
|
|
@@ -213,6 +276,7 @@ export function projectWorkItemSummary(detail) {
|
|
|
213
276
|
status: detail.status,
|
|
214
277
|
currentActionId: detail.currentActionId || null,
|
|
215
278
|
executionStats: sumExecutionStats(Array.isArray(detail.runs) ? detail.runs : []),
|
|
279
|
+
failureReason: workItemFailureReason(detail),
|
|
216
280
|
currentAction: projectedAction ? {
|
|
217
281
|
id: projectedAction.id,
|
|
218
282
|
type: projectedAction.type,
|
|
@@ -327,7 +327,7 @@ function completionContract(action, workItem) {
|
|
|
327
327
|
? ',\n "contractPatch": { "goal": "optional refined goal", "acceptanceCriteria": ["optional refined criterion"] }'
|
|
328
328
|
: '';
|
|
329
329
|
const planField = action.type === 'triage' && workItem?.workflowSnapshot?.planningMode === 'ai'
|
|
330
|
-
? ',\n "plan": { "workItemType": "specific-lowercase-slug", "actions": [{ "id": "stable-id", "name": "User-facing name", "type": "extensible-lowercase-slug (built-ins include research|design|diagnose|implement|migrate|test|review|document|operate|deliver|integrate|write|custom)", "capability": "specific executor capability", "objective": "
|
|
330
|
+
? ',\n "plan": { "workItemType": "specific-lowercase-slug", "actions": [{ "id": "stable-id", "name": "User-facing name", "type": "extensible-lowercase-slug (built-ins include research|design|diagnose|implement|migrate|test|review|document|operate|deliver|integrate|write|custom)", "capability": "specific executor capability", "objective": "task-specific concrete work this Action must do", "approach": "task-specific repository-aware method the executor must follow", "expectedOutcome": "task-specific verifiable result this Action must produce", "dependsOnActionIds": ["earlier Action id; [] means concurrent root"], "workspaceMode": "read|isolated-write|integrate|shared", "separateFromActionTypes": ["optional prior Action type"], "changesRequestedActionId": "for review: optional earlier editable Action id; omit to use nearest", "maxAttempts": 2 }] }'
|
|
331
331
|
: '';
|
|
332
332
|
const acceptanceChecks = (workItem?.acceptanceCriteria || []).map(criterion => ({
|
|
333
333
|
criterion,
|
|
@@ -341,7 +341,7 @@ export function resolvePlanningWorkflowSnapshot(settings, requestedWorkItemType
|
|
|
341
341
|
const typeInstruction = requestedType
|
|
342
342
|
? `The user explicitly selected workItemType "${requestedType}". Keep that exact type.`
|
|
343
343
|
: 'Infer one specific workItemType from the contract.';
|
|
344
|
-
const triageInstruction = `${normalized.actionInstructions.triage}\n\n${typeInstruction}\nReusable Action templates:\n${catalog || '(none)'}\nIf the final type matches a reusable template, return its workItemType and omit actions so Work Center can apply that frozen template. Otherwise generate the smallest reliable graph of 1 to 8 Actions. Split independent work into separate Actions and declare dependsOnActionIds. Use workspaceMode read for analysis, isolated-write for independent Git changes, integrate for an integrate Action that combines isolated-write dependencies, and shared for serial side effects. Non-Git or dirty workspaces are serialized automatically. Every generated Action must state objective, approach, expectedOutcome, capability, dependencies, and workspaceMode. Add only Actions required by this task. Do not copy a generic workflow.`;
|
|
344
|
+
const triageInstruction = `${normalized.actionInstructions.triage}\n\n${typeInstruction}\nReusable Action templates:\n${catalog || '(none)'}\nIf the final type matches a reusable template, return its workItemType and omit actions so Work Center can apply that frozen template. Otherwise generate the smallest reliable graph of 1 to 8 Actions. Split independent work into separate Actions and declare dependsOnActionIds. Use workspaceMode read for analysis, isolated-write for independent Git changes, integrate for an integrate Action that combines isolated-write dependencies, and shared for serial side effects. Non-Git or dirty workspaces are serialized automatically. Every generated Action must state objective, approach, expectedOutcome, capability, dependencies, and workspaceMode. The objective, approach, and expectedOutcome must be specific to this WorkItem and that Action: describe the concrete work, the repository-aware execution method, and the verifiable result that will guide the executor. Generic Action-type boilerplate is invalid. Add only Actions required by this task. Do not copy a generic workflow.`;
|
|
345
345
|
return normalizeWorkflowDefinition({
|
|
346
346
|
id: 'ai-planned',
|
|
347
347
|
name: 'AI planned',
|
|
@@ -434,14 +434,20 @@ export function applyGeneratedPlan(workItem, rawPlan) {
|
|
|
434
434
|
if (seen.has(id)) throw new Error(`Duplicate AI-planned Action id: ${id}`);
|
|
435
435
|
seen.add(id);
|
|
436
436
|
const objective = typeof input.objective === 'string' ? input.objective.trim().slice(0, 2_000) : '';
|
|
437
|
-
if (!objective) throw new Error(`AI-planned Action "${id}" requires
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
: defaults.approach;
|
|
442
|
-
const expectedOutcome = typeof input.expectedOutcome === 'string' && input.expectedOutcome.trim()
|
|
437
|
+
if (!objective) throw new Error(`AI-planned Action "${id}" requires a task-specific objective`);
|
|
438
|
+
const approach = typeof input.approach === 'string' ? input.approach.trim().slice(0, 2_000) : '';
|
|
439
|
+
if (!approach) throw new Error(`AI-planned Action "${id}" requires a task-specific approach`);
|
|
440
|
+
const expectedOutcome = typeof input.expectedOutcome === 'string'
|
|
443
441
|
? input.expectedOutcome.trim().slice(0, 2_000)
|
|
444
|
-
:
|
|
442
|
+
: '';
|
|
443
|
+
if (!expectedOutcome) {
|
|
444
|
+
throw new Error(`AI-planned Action "${id}" requires a task-specific expectedOutcome`);
|
|
445
|
+
}
|
|
446
|
+
const defaults = defaultActionBrief(type);
|
|
447
|
+
if (objective === defaults.objective || approach === defaults.approach
|
|
448
|
+
|| expectedOutcome === defaults.expectedOutcome) {
|
|
449
|
+
throw new Error(`AI-planned Action "${id}" must not reuse generic Action-type brief text`);
|
|
450
|
+
}
|
|
445
451
|
const actionInstruction = Object.hasOwn(source.actionInstructions, type)
|
|
446
452
|
? source.actionInstructions[type]
|
|
447
453
|
: source.actionInstructions.custom;
|