@inetafrica/open-claudia 3.5.19 → 3.5.23
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/.env.example +8 -3
- package/CHANGELOG.md +37 -0
- package/README.md +5 -4
- package/bin/cli.js +6 -0
- package/bin/teach.js +78 -0
- package/bot.js +7 -0
- package/channels/kazee/adapter.js +30 -0
- package/core/actions.js +4 -0
- package/core/auth-flow.js +7 -2
- package/core/bot-apps.js +133 -0
- package/core/browser-control.js +540 -0
- package/core/browser-lock.js +51 -0
- package/core/browser-teach.js +1012 -0
- package/core/browser-view.js +5 -2
- package/core/cards.js +4 -2
- package/core/dream.js +132 -10
- package/core/handlers.js +16 -2
- package/core/limit-pickup.js +24 -20
- package/core/providers/claude.js +15 -8
- package/core/providers/events.js +25 -1
- package/core/providers/utility-policy.js +31 -6
- package/core/runner.js +203 -10
- package/core/scheduler.js +57 -0
- package/core/state.js +14 -0
- package/core/system-prompt.js +12 -2
- package/core/tool-guard.js +343 -0
- package/core/turn-journal.js +55 -2
- package/core/usage-limits.js +110 -0
- package/core/utility-agent.js +109 -15
- package/package.json +2 -2
- package/test-fixtures/fake-agent-cli.js +46 -2
- package/test-provider-boot-matrix.js +6 -2
- package/test-provider-capabilities.js +1 -1
- package/test-provider-claude.js +7 -5
- package/test-provider-events.js +43 -1
- package/test-provider-language.js +15 -1
- package/web.js +68 -0
package/core/runner.js
CHANGED
|
@@ -15,7 +15,7 @@ const {
|
|
|
15
15
|
const {
|
|
16
16
|
currentState, saveState, recordSession,
|
|
17
17
|
resetSessionUsage, admitLane, cancelTargets, getActiveProvider, getProjectKey,
|
|
18
|
-
laneFor, laneForRun, peekLane, laneBusy, runLaneKey, waitingLanes, slotBlocker, pruneIdleLanes,
|
|
18
|
+
laneFor, laneForRun, peekLane, laneBusy, runLaneKey, waitingLanes, slotBlocker, pruneIdleLanes, findQueued,
|
|
19
19
|
getProviderSession, setProviderSession, clearProviderSession, quarantineProviderSession, getUsageForSession, commitProviderCompaction, commitBranchCompaction,
|
|
20
20
|
resolveProviderSettings, getProjectSessions,
|
|
21
21
|
} = require("./state");
|
|
@@ -26,7 +26,7 @@ const usageLimits = require("./usage-limits");
|
|
|
26
26
|
const limitPickup = require("./limit-pickup");
|
|
27
27
|
const { getProvider, resolveProviderForRun } = require("./providers");
|
|
28
28
|
const { validateInvocation } = require("./providers/contract");
|
|
29
|
-
const { selectTerminalText } = require("./providers/events");
|
|
29
|
+
const { providerFailureCode, selectTerminalText } = require("./providers/events");
|
|
30
30
|
const {
|
|
31
31
|
bindRunInputs,
|
|
32
32
|
captureRunContext,
|
|
@@ -581,7 +581,7 @@ async function executeProviderInvocation(options = {}) {
|
|
|
581
581
|
const event = rawErrors[0];
|
|
582
582
|
failure = stableRunError(
|
|
583
583
|
event.message || "Provider reported an error",
|
|
584
|
-
|
|
584
|
+
providerFailureCode(event, "PROVIDER_EVENT_ERROR"),
|
|
585
585
|
event,
|
|
586
586
|
);
|
|
587
587
|
} else if (closeInfo.code !== 0) failure = stableRunError(`Provider process exited with code ${closeInfo.code}`, "PROVIDER_NONZERO_EXIT", { exitCode: closeInfo.code });
|
|
@@ -620,6 +620,9 @@ async function executeProviderInvocation(options = {}) {
|
|
|
620
620
|
delivery,
|
|
621
621
|
timings: { startedAt, finishedAt: null, durationMs: null },
|
|
622
622
|
};
|
|
623
|
+
// Every run, whatever its purpose, keeps the limit record current: a limit
|
|
624
|
+
// hit marks the provider used up until its reset, a run that worked clears it.
|
|
625
|
+
noteRunLimit(result, runContext);
|
|
623
626
|
|
|
624
627
|
const runStage = async (name, hook) => {
|
|
625
628
|
if (!hook) return;
|
|
@@ -1773,6 +1776,23 @@ function limitPlanFor(result, runContext) {
|
|
|
1773
1776
|
return keepOnResult(result, "limitPlan", plan);
|
|
1774
1777
|
}
|
|
1775
1778
|
|
|
1779
|
+
// The limit record (core/usage-limits.js) background work reads: a run a
|
|
1780
|
+
// limit stopped marks the provider used up until the reset; a run that
|
|
1781
|
+
// worked clears it. Never throws.
|
|
1782
|
+
function noteRunLimit(result, runContext) {
|
|
1783
|
+
try {
|
|
1784
|
+
const provider = runContext?.provider || result.provider;
|
|
1785
|
+
if (result.ok) { usageLimits.clearLimitBlock(provider, { model: runContext?.providerSettings?.model || null }); return; }
|
|
1786
|
+
if (result.error?.code !== "PROVIDER_USAGE_LIMIT") return;
|
|
1787
|
+
const hit = usageLimits.limitHitDetails({
|
|
1788
|
+
provider, error: result.error, rateLimit: result.rateLimit, windows: planLimitWindows(result, runContext || {}),
|
|
1789
|
+
});
|
|
1790
|
+
if (hit?.resetsAt) usageLimits.noteLimitHit({ provider, limitType: hit.limitType, resetsAt: hit.resetsAt });
|
|
1791
|
+
} catch (error) {
|
|
1792
|
+
console.warn(`[usage-limits] limit record: ${redactSensitive(String(error.message || error))}`);
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1776
1796
|
// Remember each window's use; the warnings now due (80% / 95%, once per
|
|
1777
1797
|
// window). A run that cannot show them only updates the use, so the warning
|
|
1778
1798
|
// is still due on the next turn that can.
|
|
@@ -2637,16 +2657,18 @@ function cancelQueuedCallOnAbort(lane, item) {
|
|
|
2637
2657
|
|
|
2638
2658
|
// Admit waiting lanes into free slots: every lane with queued work and no run,
|
|
2639
2659
|
// oldest waiting message first, as far as the caps allow. An admitted lane runs
|
|
2640
|
-
//
|
|
2641
|
-
// captured context. Called whenever a
|
|
2642
|
-
//
|
|
2643
|
-
// slot ahead of a lane that was
|
|
2644
|
-
// considered (the global cap is
|
|
2660
|
+
// what waits in it as ONE turn: the person's queued messages together (see
|
|
2661
|
+
// takeQueueBatch), in the first message's captured context. Called whenever a
|
|
2662
|
+
// run, a compaction or a /stop frees a lane, and synchronous up to the
|
|
2663
|
+
// admission, so no newcomer can take a freed slot ahead of a lane that was
|
|
2664
|
+
// already waiting. Every state's lanes are considered (the global cap is
|
|
2665
|
+
// shared); the argument is kept for callers.
|
|
2645
2666
|
function drainQueuedMessages(_state) {
|
|
2646
2667
|
for (const { state, lane } of waitingLanes()) {
|
|
2647
2668
|
if (laneBusy(lane) || !lane.messageQueue.length) continue;
|
|
2648
2669
|
if (slotBlocker(state)) { lane.waitingForSlot = true; continue; }
|
|
2649
|
-
const
|
|
2670
|
+
const batch = takeQueueBatch(lane);
|
|
2671
|
+
const item = batch.length > 1 ? mergeQueueBatch(lane, batch) : batch[0];
|
|
2650
2672
|
item.disposeCallListener?.();
|
|
2651
2673
|
lane.preparingRun = true;
|
|
2652
2674
|
lane.waitingForSlot = false;
|
|
@@ -2666,6 +2688,163 @@ function queueInLane(lane, item) {
|
|
|
2666
2688
|
return item;
|
|
2667
2689
|
}
|
|
2668
2690
|
|
|
2691
|
+
// ── The queue: run together, remove one ────────────────────────────────
|
|
2692
|
+
// Messages a person sends while their conversation is busy used to run one
|
|
2693
|
+
// turn each, in series, every one answering without the others. Now, when the
|
|
2694
|
+
// lane frees, the queued messages run as one turn (QUEUE_BATCH=off restores
|
|
2695
|
+
// one at a time). Only plain chat messages from the same person, in the same
|
|
2696
|
+
// session, project, provider and model join; anything else (a scheduled run,
|
|
2697
|
+
// a side chat's follow-up, a limit pick-up, a live call) runs on its own, in
|
|
2698
|
+
// order. Up to QUEUE_BATCH_MAX (10) per turn.
|
|
2699
|
+
const QUEUE_BATCH_MAX = 10;
|
|
2700
|
+
function queueBatchOn() {
|
|
2701
|
+
return !/^(off|false|0|no)$/i.test(String(process.env.QUEUE_BATCH ?? config.QUEUE_BATCH ?? "on").trim());
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
function queuedSender(item) {
|
|
2705
|
+
return String(item?.store?.canonicalUserId || item?.runContext?.canonicalUserId || "");
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
function batchable(item) {
|
|
2709
|
+
const runContext = item?.runContext;
|
|
2710
|
+
return !!runContext
|
|
2711
|
+
&& !item.pendingSideChat
|
|
2712
|
+
&& !item.followMainRunId
|
|
2713
|
+
&& runContext.purpose === "foreground"
|
|
2714
|
+
&& !runContext.liveCall
|
|
2715
|
+
&& !runContext.limitPickup
|
|
2716
|
+
&& item.store?.adapter?.liveCall !== true;
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
function sameBatch(a, b) {
|
|
2720
|
+
const x = a.runContext;
|
|
2721
|
+
const y = b.runContext;
|
|
2722
|
+
return !!queuedSender(a)
|
|
2723
|
+
&& queuedSender(a) === queuedSender(b)
|
|
2724
|
+
&& x.provider === y.provider
|
|
2725
|
+
&& (x.project?.dir || null) === (y.project?.dir || null)
|
|
2726
|
+
&& (x.sessionId || null) === (y.sessionId || null)
|
|
2727
|
+
&& (x.providerSettings?.model || null) === (y.providerSettings?.model || null);
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
// The next turn's messages, taken off the front of the lane's queue.
|
|
2731
|
+
function takeQueueBatch(lane) {
|
|
2732
|
+
const first = lane.messageQueue.shift();
|
|
2733
|
+
const batch = [first];
|
|
2734
|
+
if (!queueBatchOn() || !batchable(first)) return batch;
|
|
2735
|
+
while (lane.messageQueue.length && batch.length < QUEUE_BATCH_MAX) {
|
|
2736
|
+
const next = lane.messageQueue[0];
|
|
2737
|
+
if (!batchable(next) || !sameBatch(first, next)) break;
|
|
2738
|
+
batch.push(lane.messageQueue.shift());
|
|
2739
|
+
}
|
|
2740
|
+
return batch;
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
function queueClock(ms) {
|
|
2744
|
+
return new Date(ms).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", hour12: false });
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
function batchPrompt(batch) {
|
|
2748
|
+
const n = batch.length;
|
|
2749
|
+
const parts = [`[${n} messages arrived while you were busy. Read them all, then reply once: answer each, and where a later one changes or cancels an earlier one, the later one wins.]`];
|
|
2750
|
+
batch.forEach((item, index) => {
|
|
2751
|
+
const at = Number(item.queuedAt);
|
|
2752
|
+
const when = Number.isFinite(at) && at > 0 ? ` (${queueClock(at)})` : "";
|
|
2753
|
+
parts.push(`--- Message ${index + 1} of ${n}${when} ---\n${item.runContext.userPrompt}`);
|
|
2754
|
+
});
|
|
2755
|
+
return parts.join("\n\n");
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
// One queue item for the whole batch: the first message's captured context
|
|
2759
|
+
// (its session and lineage), every message's text, images and files, and the
|
|
2760
|
+
// newest message as the one the reply answers. Every sender's caller gets the
|
|
2761
|
+
// one result; the journal keeps the batch as the first message's turn.
|
|
2762
|
+
function mergeQueueBatch(lane, batch) {
|
|
2763
|
+
const first = batch[0];
|
|
2764
|
+
const last = batch[batch.length - 1];
|
|
2765
|
+
for (const item of batch.slice(1)) {
|
|
2766
|
+
try { item.disposeCallListener?.(); } catch (_) {}
|
|
2767
|
+
}
|
|
2768
|
+
const runContext = deepFreeze({
|
|
2769
|
+
...bindRunInputs(first.runContext, {
|
|
2770
|
+
userPrompt: batchPrompt(batch),
|
|
2771
|
+
imagePaths: batch.flatMap((item) => item.runContext.imagePaths || []),
|
|
2772
|
+
attachments: batch.flatMap((item) => item.runContext.attachments || []),
|
|
2773
|
+
lastInputWasVoice: !!last.runContext.lastInputWasVoice,
|
|
2774
|
+
voiceStream: !!last.runContext.voiceStream,
|
|
2775
|
+
}),
|
|
2776
|
+
replyToMsgId: last.runContext.replyToMsgId ?? first.runContext.replyToMsgId ?? null,
|
|
2777
|
+
});
|
|
2778
|
+
try { turnJournal.dropQueued(lane.key, batch.slice(1).map((item) => item.runContext.runId).filter(Boolean)); } catch (_) {}
|
|
2779
|
+
const words = batch.map((item) => String(item.store?.inboundText || "").trim()).filter(Boolean);
|
|
2780
|
+
const store = last.store ? { ...last.store, inboundText: words.join("\n\n") } : first.store;
|
|
2781
|
+
console.log(`[queue] ${lane.key}: ${batch.length} queued messages run as one turn (run ${runContext.runId || "?"})`);
|
|
2782
|
+
return {
|
|
2783
|
+
runContext,
|
|
2784
|
+
store,
|
|
2785
|
+
queuedAt: first.queuedAt,
|
|
2786
|
+
seq: first.seq,
|
|
2787
|
+
text: batch.map((item) => item.text).filter(Boolean).join(" / "),
|
|
2788
|
+
deferred: {
|
|
2789
|
+
resolve: (result) => { for (const item of batch) item.deferred?.resolve(result); },
|
|
2790
|
+
reject: (error) => { for (const item of batch) item.deferred?.reject(error); },
|
|
2791
|
+
},
|
|
2792
|
+
};
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
// The ✕ on a "Queued." notice takes that one message out of its queue
|
|
2796
|
+
// (callback "qx:<boot>:<seq>"). The boot id keeps a button from before a
|
|
2797
|
+
// restart from naming a message that got the same number since.
|
|
2798
|
+
const QUEUE_BOOT = crypto.randomBytes(3).toString("hex");
|
|
2799
|
+
|
|
2800
|
+
function queueRemoveKeyboard(item) {
|
|
2801
|
+
if (!item?.runContext || item.runContext.liveCall || !Number.isSafeInteger(item.seq)) return null;
|
|
2802
|
+
return { inline_keyboard: [[{ text: "✕ Remove from queue", callback_data: `qx:${QUEUE_BOOT}:${item.seq}` }]] };
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
function queuedWords(item) {
|
|
2806
|
+
const text = String(item?.text || item?.runContext?.userPrompt || "").replace(/\s+/g, " ").trim();
|
|
2807
|
+
return text.length > 40 ? `${text.slice(0, 39)}…` : (text || "…");
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
// Only the person whose message it is, or the owner, in the chat it was sent in.
|
|
2811
|
+
function mayRemoveQueued(envelope, item, opts = {}) {
|
|
2812
|
+
const channelId = item.store?.channelId || item.runContext?.channelId || "";
|
|
2813
|
+
if (String(envelope?.channelId || "") !== String(channelId)) return false;
|
|
2814
|
+
if (opts.isOwner === true) return true;
|
|
2815
|
+
const sender = queuedSender(item);
|
|
2816
|
+
return !!sender && String(envelope?.canonicalUserId || "") === sender;
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
async function handleQueueRemove(data, envelope, opts = {}) {
|
|
2820
|
+
const [, boot, seqText] = String(data || "").split(":");
|
|
2821
|
+
const seq = Number(seqText);
|
|
2822
|
+
const noticeId = envelope?.action?.sourceMessageId || null;
|
|
2823
|
+
const answer = (text) => (noticeId ? editMessage(String(noticeId), text) : send(text));
|
|
2824
|
+
const hit = boot === QUEUE_BOOT && Number.isSafeInteger(seq) ? findQueued((item) => item.seq === seq) : null;
|
|
2825
|
+
if (!hit) return answer("No longer queued: it already started (or was dropped). /stop cancels a running turn.");
|
|
2826
|
+
const { lane, item } = hit;
|
|
2827
|
+
if (!mayRemoveQueued(envelope, item, opts)) return send("That button belongs to another person.");
|
|
2828
|
+
const index = lane.messageQueue.indexOf(item);
|
|
2829
|
+
if (index >= 0) lane.messageQueue.splice(index, 1);
|
|
2830
|
+
try { item.disposeCallListener?.(); } catch (_) {}
|
|
2831
|
+
try { turnJournal.dropQueued(lane.key, [item.runContext?.runId].filter(Boolean)); } catch (_) {}
|
|
2832
|
+
if (!lane.messageQueue.length) {
|
|
2833
|
+
lane.waitingForSlot = false;
|
|
2834
|
+
if (!laneBusy(lane)) stopLaneTyping(lane);
|
|
2835
|
+
}
|
|
2836
|
+
item.deferred?.resolve({
|
|
2837
|
+
ok: false,
|
|
2838
|
+
status: "cancelled",
|
|
2839
|
+
runId: item.runContext?.runId || null,
|
|
2840
|
+
provider: item.runContext?.provider || null,
|
|
2841
|
+
project: item.runContext?.project || null,
|
|
2842
|
+
error: { code: "QUEUE_REMOVED", message: "Removed from the queue" },
|
|
2843
|
+
});
|
|
2844
|
+
console.log(`[queue] ${lane.key}: removed queued message ${seq} (${lane.messageQueue.length} left)`);
|
|
2845
|
+
return answer(`Removed from the queue: "${queuedWords(item)}"`);
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2669
2848
|
// A refusal on a RESUMED session is almost always the safety classifier
|
|
2670
2849
|
// rejecting flagged material in the old context — which every resume re-sends
|
|
2671
2850
|
// — not the user's new message. Recovery: quarantine the poisoned session so
|
|
@@ -2781,6 +2960,16 @@ async function runAdmitted(runContext, state, store, capture, ownsAdmissionLock,
|
|
|
2781
2960
|
const persistCapture = executionOptions.persistCapture !== false;
|
|
2782
2961
|
const internalCapture = capture && !persistCapture;
|
|
2783
2962
|
if (tracksMainRun) lane.activeRunContext = runContext;
|
|
2963
|
+
// The turn after a /stop: the stopped request is in the session's history
|
|
2964
|
+
// looking cut off, so this turn opens with a note that it was cancelled.
|
|
2965
|
+
if (tracksMainRun && runContext.purpose === "foreground" && !runContext.liveCall) {
|
|
2966
|
+
const stopped = turnJournal.takeStopped(runLaneKey(runContext));
|
|
2967
|
+
if (stopped) {
|
|
2968
|
+
const noted = bindRunInputs(runContext, { userPrompt: `${turnJournal.stoppedNote(stopped)}\n\n${runContext.userPrompt}` });
|
|
2969
|
+
if (lane.activeRunContext === runContext) lane.activeRunContext = noted;
|
|
2970
|
+
runContext = noted;
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2784
2973
|
// Turn journal "lock": a foreground chat turn that a restart cuts short is
|
|
2785
2974
|
// resumed after boot (core/turn-journal).
|
|
2786
2975
|
if (tracksMainRun) turnJournal.begin(runContext);
|
|
@@ -3010,7 +3199,8 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
3010
3199
|
const notice = admission.reason === "busy"
|
|
3011
3200
|
? (lane.isCompacting ? "Compacting context, will pick this up next…" : queuedNotice(lane.activeRunContext))
|
|
3012
3201
|
: waitingNotice(admission.running);
|
|
3013
|
-
|
|
3202
|
+
const keyboard = queueRemoveKeyboard(item);
|
|
3203
|
+
send(notice, keyboard ? { replyTo: replyToMsgId, keyboard } : { replyTo: replyToMsgId }).catch(() => {});
|
|
3014
3204
|
}
|
|
3015
3205
|
return deferred.promise;
|
|
3016
3206
|
}
|
|
@@ -3110,6 +3300,9 @@ function runAgentSilent(prompt, cwd, label, opts = {}) {
|
|
|
3110
3300
|
|
|
3111
3301
|
module.exports = {
|
|
3112
3302
|
queuedNotice,
|
|
3303
|
+
handleQueueRemove,
|
|
3304
|
+
takeQueueBatch,
|
|
3305
|
+
mergeQueueBatch,
|
|
3113
3306
|
waitingNotice,
|
|
3114
3307
|
deliverForegroundResult,
|
|
3115
3308
|
drainQueuedMessages,
|
package/core/scheduler.js
CHANGED
|
@@ -44,6 +44,10 @@ const { providerStatus: defaultProviderStatus } = require("./providers");
|
|
|
44
44
|
const { redactSensitive } = require("./redact");
|
|
45
45
|
|
|
46
46
|
const MISSED_WAKEUP_GRACE_MS = 60 * 60 * 1000;
|
|
47
|
+
// A new occurrence starts with no usage-limit wait behind it.
|
|
48
|
+
const LIMIT_WAIT_CLEARED = Object.freeze({
|
|
49
|
+
limitWaitCount: 0, limitWaitUntil: null, limitWaitType: null, limitWaitProvider: null, limitFirstHitAt: null,
|
|
50
|
+
});
|
|
47
51
|
const RETRY_DELAY_MS = 30 * 1000;
|
|
48
52
|
const BUSY_DELAY_MS = 30 * 1000;
|
|
49
53
|
// A watch alarm that has waited this long behind a busy room says so in the
|
|
@@ -247,6 +251,9 @@ function createSchedulerService(dependencies = {}) {
|
|
|
247
251
|
? (dependencies.jobsStore ? null : CRONS_FILE)
|
|
248
252
|
: dependencies.legacyCronsFile;
|
|
249
253
|
const runInProvider = dependencies.runAgent || ((...args) => require("./runner").runAgent(...args));
|
|
254
|
+
// What a run a usage limit stopped does (core/limit-pickup.js planFor): the
|
|
255
|
+
// same rule and wording as a chat message the limit stopped.
|
|
256
|
+
const planLimit = dependencies.planLimit || ((...args) => require("./limit-pickup").planFor(...args));
|
|
250
257
|
const admit = dependencies.admitRunContext || ((...args) => require("./runner").admitRunContext(...args));
|
|
251
258
|
const projectSessions = dependencies.getProjectSessions || getProjectSessions;
|
|
252
259
|
const timers = new Map();
|
|
@@ -510,6 +517,9 @@ function createSchedulerService(dependencies = {}) {
|
|
|
510
517
|
}
|
|
511
518
|
const polled = job.check ? pendingPollNote(job) : null;
|
|
512
519
|
if (polled) lines.push(polled);
|
|
520
|
+
if (Number(job.limitWaitCount) > 0 && Number.isFinite(Number(job.limitFirstHitAt))) {
|
|
521
|
+
lines.push(`[A usage limit stopped this run at ${new Date(Number(job.limitFirstHitAt)).toISOString()}; it waited for the reset and runs now. Check what still applies before acting.]`);
|
|
522
|
+
}
|
|
513
523
|
if (target.fallbackFrom) {
|
|
514
524
|
lines.push(`[Archived provider-neutral brief: the originating ${target.fallbackFrom} provider is unavailable. Its native session is not being resumed; this explicitly configured fallback starts fresh.]`);
|
|
515
525
|
} else if (target.revertedFrom) {
|
|
@@ -653,6 +663,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
653
663
|
watchLastHitAt: null,
|
|
654
664
|
watchLastClearAt: null,
|
|
655
665
|
advisoryOccurrenceId: null,
|
|
666
|
+
...LIMIT_WAIT_CLEARED,
|
|
656
667
|
};
|
|
657
668
|
const advanced = jobsStore.update(job.id, advance) || { ...latest, ...advance };
|
|
658
669
|
unscheduleJob(job.id);
|
|
@@ -705,6 +716,41 @@ function createSchedulerService(dependencies = {}) {
|
|
|
705
716
|
return { ok: false, deferred: true, status: updated.status, job: updated };
|
|
706
717
|
}
|
|
707
718
|
|
|
719
|
+
// The usage-limit plan of a failed run, or null (the runner keeps it on the
|
|
720
|
+
// result; a run that never reached delivery is planned here).
|
|
721
|
+
function limitPlanOf(result, runContext) {
|
|
722
|
+
if (result?.error?.code !== "PROVIDER_USAGE_LIMIT") return null;
|
|
723
|
+
if (result.limitPlan !== undefined) return result.limitPlan;
|
|
724
|
+
try { return planLimit(result, runContext, { now: now() }); }
|
|
725
|
+
catch (_) { return null; }
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// A usage limit is not a failed attempt: the occurrence waits for the reset,
|
|
729
|
+
// as a chat message does (core/limit-pickup.js), and never moves to another
|
|
730
|
+
// provider (it may not be signed in, or be used up too). The notice was
|
|
731
|
+
// posted with the run's result.
|
|
732
|
+
function markLimitWait(job, plan, result) {
|
|
733
|
+
const latest = jobsStore.get(job.id) || job;
|
|
734
|
+
const patch = {
|
|
735
|
+
status: "deferred",
|
|
736
|
+
disabled: false,
|
|
737
|
+
attemptCount: Math.max(0, (Number(latest.attemptCount) || 1) - 1),
|
|
738
|
+
nextAttemptAt: plan.runAt,
|
|
739
|
+
lastFireAt: now(),
|
|
740
|
+
lastFireOk: false,
|
|
741
|
+
lastError: stableFailure(result),
|
|
742
|
+
limitWaitCount: (Number(latest.limitWaitCount) || 0) + 1,
|
|
743
|
+
limitWaitUntil: plan.runAt,
|
|
744
|
+
limitWaitType: plan.hit?.limitType || null,
|
|
745
|
+
limitWaitProvider: plan.hit?.provider || null,
|
|
746
|
+
limitFirstHitAt: Number(latest.limitFirstHitAt) || now(),
|
|
747
|
+
};
|
|
748
|
+
const updated = jobsStore.update(job.id, patch) || { ...latest, ...patch };
|
|
749
|
+
armRetry(updated);
|
|
750
|
+
console.log(`[scheduler] ${job.id}: ${patch.limitWaitProvider || "provider"} ${patch.limitWaitType || "usage limit"} used up; waits until ${new Date(plan.runAt).toISOString()} (wait ${patch.limitWaitCount})`);
|
|
751
|
+
return { ok: false, status: "deferred", limitWait: true, runAt: plan.runAt, job: updated, result };
|
|
752
|
+
}
|
|
753
|
+
|
|
708
754
|
async function settleSuccess(job, result, target) {
|
|
709
755
|
const successPatch = {
|
|
710
756
|
status: "succeeded",
|
|
@@ -759,6 +805,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
759
805
|
watchLastHitAt: null,
|
|
760
806
|
watchLastClearAt: null,
|
|
761
807
|
advisoryOccurrenceId: null,
|
|
808
|
+
...LIMIT_WAIT_CLEARED,
|
|
762
809
|
});
|
|
763
810
|
if (reset && timers.get(job.id)?.timeout) scheduleJob(reset);
|
|
764
811
|
return reset
|
|
@@ -909,6 +956,11 @@ function createSchedulerService(dependencies = {}) {
|
|
|
909
956
|
occurrenceId: job.occurrenceId,
|
|
910
957
|
scheduledJobId: job.id,
|
|
911
958
|
originRunId: job.originRunId || null,
|
|
959
|
+
// A run that waited for a limit's reset: a new hit says "still used up"
|
|
960
|
+
// and counts towards the most waits (limit-pickup MAX_ATTEMPTS).
|
|
961
|
+
...(Number(job.limitWaitCount) > 0 ? {
|
|
962
|
+
limitPickup: { attempts: Number(job.limitWaitCount), firstHitAt: Number(job.limitFirstHitAt) || null, limitType: job.limitWaitType || null },
|
|
963
|
+
} : {}),
|
|
912
964
|
};
|
|
913
965
|
// Admission is synchronous and precedes all notification delivery. A
|
|
914
966
|
// refusal is a failed attempt, never a rejection that drops the job.
|
|
@@ -978,6 +1030,11 @@ function createSchedulerService(dependencies = {}) {
|
|
|
978
1030
|
catch (persistError) { return keepRetrying(running, persistError); }
|
|
979
1031
|
}
|
|
980
1032
|
if (!terminalRunSucceeded(result)) {
|
|
1033
|
+
const limitPlan = limitPlanOf(result, runContext);
|
|
1034
|
+
if (limitPlan?.waits && Number.isFinite(limitPlan.runAt)) {
|
|
1035
|
+
try { return markLimitWait(running, limitPlan, result); }
|
|
1036
|
+
catch (error) { return { ...keepRetrying(running, error), result }; }
|
|
1037
|
+
}
|
|
981
1038
|
try { return markFailure(running, result, { attemptAlreadyCounted: true }); }
|
|
982
1039
|
catch (error) { return { ...keepRetrying(running, error), result }; }
|
|
983
1040
|
}
|
package/core/state.js
CHANGED
|
@@ -1045,6 +1045,19 @@ function waitingLanes() {
|
|
|
1045
1045
|
});
|
|
1046
1046
|
}
|
|
1047
1047
|
|
|
1048
|
+
// A queued message anywhere (every person's state, every conversation), for
|
|
1049
|
+
// the ✕ on its "Queued." notice. Returns { state, lane, item } or null.
|
|
1050
|
+
function findQueued(predicate) {
|
|
1051
|
+
for (const state of statesWithLanes()) {
|
|
1052
|
+
if (!(state.lanes instanceof Map)) continue;
|
|
1053
|
+
for (const lane of state.lanes.values()) {
|
|
1054
|
+
const item = lane.messageQueue.find(predicate);
|
|
1055
|
+
if (item) return { state, lane, item };
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
return null;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1048
1061
|
// /stop arms lane.cancelRequested with the id of the run it stops (true only
|
|
1049
1062
|
// when no run context is known), and a run acts on the flag only when it names
|
|
1050
1063
|
// that run. A bare boolean let a stop outlive its run and cancel the next one.
|
|
@@ -1898,6 +1911,7 @@ module.exports = {
|
|
|
1898
1911
|
runningLanes,
|
|
1899
1912
|
slotBlocker,
|
|
1900
1913
|
waitingLanes,
|
|
1914
|
+
findQueued,
|
|
1901
1915
|
pruneIdleLanes,
|
|
1902
1916
|
maxParallelTurns,
|
|
1903
1917
|
maxParallelTurnsGlobal,
|
package/core/system-prompt.js
CHANGED
|
@@ -239,6 +239,16 @@ Raw shell one-liners and heredocs are for probing and diagnosis only — never t
|
|
|
239
239
|
${listing}`;
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
// Kazee Chat's Browser app (core/bot-apps.js): only on a bot that offers it, so a bot without
|
|
243
|
+
// Agent Space is never told to send an Open Browser button that cannot open.
|
|
244
|
+
function buildBrowserAppBlock() {
|
|
245
|
+
let apps = null;
|
|
246
|
+
try { apps = require("./bot-apps").appsToRegister(process.env); } catch (_) { apps = null; }
|
|
247
|
+
if (!apps || !apps.some((app) => app.key === "browser")) return "";
|
|
248
|
+
return `
|
|
249
|
+
Kazee Chat Browser app: people in a direct chat with you can watch this browser live, take it over, and teach you a task (Teach me). When you need a person in the browser (a login, a captcha, a one-time code, a judgement call), send a card in their direct chat with an Open Browser button, say what you need, then wait: you are woken in that chat when they hand it back. For example \`open-claudia card send '{"v":1,"kind":"report","title":"I need you in the browser","body":"<what you need and why>","open":{"app":"browser","label":"Open Browser"}}'\`. While someone drives, your agent-browser commands other than get, snapshot and screenshot are refused. Never ask for passwords in chat.`;
|
|
250
|
+
}
|
|
251
|
+
|
|
242
252
|
function buildCoreInstructionsUncached() {
|
|
243
253
|
const soul = loadSoul();
|
|
244
254
|
// Gate on actual TTS capability, not local Whisper: hosted pods hear and
|
|
@@ -368,11 +378,11 @@ Core workflow — open, snapshot to get element refs, then act on those refs:
|
|
|
368
378
|
- \`agent-browser eval "<js>"\` — run JS in the page when no command fits.
|
|
369
379
|
- \`agent-browser close\` — end the session when done.
|
|
370
380
|
|
|
371
|
-
Notes: a background daemon persists between commands, so navigation state carries across calls in a turn. Prefer \`snapshot -i\` refs over guessing selectors. Keep output lean by acting on refs rather than dumping full HTML. This replaced the old Playwright MCP — do not look for a playwright tool
|
|
381
|
+
Notes: a background daemon persists between commands, so navigation state carries across calls in a turn. Prefer \`snapshot -i\` refs over guessing selectors. Keep output lean by acting on refs rather than dumping full HTML. This replaced the old Playwright MCP — do not look for a playwright tool.${buildBrowserAppBlock()}
|
|
372
382
|
|
|
373
383
|
If you tell the user "I'll check back in N minutes" or "I'll run this every morning", you MUST schedule it with one of the commands above in the same turn. Otherwise nothing happens.
|
|
374
384
|
|
|
375
|
-
Interrupted turns: when a restart cuts a turn short, the agent CLI resumes the conversation by writing two lines into your history, "Continue from where you left off." and the reply "No response requested.". Neither came from the user or from you, and the user never saw them. Never mention or apologise for them, and never reply "No response requested." yourself: finish the interrupted work if it still matters, then act on the current message.
|
|
385
|
+
Interrupted turns: when a restart cuts a turn short, the agent CLI resumes the conversation by writing two lines into your history, "Continue from where you left off." and the reply "No response requested.". Neither came from the user or from you, and the user never saw them. Never mention or apologise for them, and never reply "No response requested." yourself: finish the interrupted work if it still matters, then act on the current message. A /stop leaves the same two lines, but then your next message starts with a note that the person stopped that turn: the stopped request is cancelled, so never resume, redo or re-offer it unless they ask.
|
|
376
386
|
|
|
377
387
|
## Guidelines
|
|
378
388
|
- Keep responses concise — many users are on mobile.
|