clementine-agent 1.18.9 → 1.18.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/assistant.d.ts +5 -0
- package/dist/agent/assistant.js +213 -32
- package/dist/agent/local-turn.d.ts +32 -0
- package/dist/agent/local-turn.js +107 -0
- package/dist/agent/self-improve-loop.js +1 -0
- package/dist/agent/turn-policy.d.ts +5 -0
- package/dist/agent/turn-policy.js +39 -0
- package/dist/channels/discord-agent-bot.js +2 -0
- package/dist/channels/slack-agent-bot.js +1 -1
- package/dist/channels/slack.js +1 -1
- package/dist/channels/telegram.js +10 -3
- package/dist/cli/dashboard.js +540 -51
- package/dist/cli/index.js +2 -0
- package/dist/config/clementine-json.d.ts +30 -0
- package/dist/config/clementine-json.js +39 -1
- package/dist/config/effective-config.js +5 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +6 -0
- package/dist/gateway/cron-diagnostic-turn.d.ts +11 -0
- package/dist/gateway/cron-diagnostic-turn.js +242 -0
- package/dist/gateway/cron-scheduler.d.ts +1 -1
- package/dist/gateway/cron-scheduler.js +17 -3
- package/dist/gateway/failure-diagnostics.d.ts +1 -0
- package/dist/gateway/failure-diagnostics.js +126 -11
- package/dist/gateway/router.d.ts +8 -0
- package/dist/gateway/router.js +281 -7
- package/dist/index.js +3 -0
- package/package.json +2 -2
|
@@ -27,6 +27,11 @@ import { AgentManager } from './agent-manager.js';
|
|
|
27
27
|
* SDK result; this function is for pre-flight planning only.
|
|
28
28
|
*/
|
|
29
29
|
export declare function estimateTokens(text: string): number;
|
|
30
|
+
export declare function looksLikeContextThrashText(value: unknown): boolean;
|
|
31
|
+
export declare function contextThrashRecoveryNotice(): string;
|
|
32
|
+
export declare function buildContextThrashRecoveryPrompt(userRequest: string, priorFailureText?: string): string;
|
|
33
|
+
/** Autonomous jobs use this sentinel to mean "completed, but do not notify the owner." */
|
|
34
|
+
export declare function isAutonomousNothingOutput(response: string): boolean;
|
|
30
35
|
export interface ProjectMeta {
|
|
31
36
|
path: string;
|
|
32
37
|
description?: string;
|
package/dist/agent/assistant.js
CHANGED
|
@@ -35,6 +35,7 @@ import { classifyIntent, getStrategyGuidance } from './intent-classifier.js';
|
|
|
35
35
|
import { getEventLog } from './session-event-log.js';
|
|
36
36
|
import { routeToolSurface, TOOL_SURFACE_WARN_THRESHOLD } from './tool-router.js';
|
|
37
37
|
import { decideTurnPolicy } from './turn-policy.js';
|
|
38
|
+
import { loadClementineJson } from '../config/clementine-json.js';
|
|
38
39
|
// ── Channel capabilities ────────────────────────────────────────────
|
|
39
40
|
/** Map channel label to its capabilities so the agent adapts its responses. */
|
|
40
41
|
function getChannelCapabilities(channel) {
|
|
@@ -172,6 +173,37 @@ export function estimateTokens(text) {
|
|
|
172
173
|
return 0;
|
|
173
174
|
return Math.ceil(text.length / 3.3);
|
|
174
175
|
}
|
|
176
|
+
export function looksLikeContextThrashText(value) {
|
|
177
|
+
const text = String(value ?? '');
|
|
178
|
+
return /autocompact\s+is\s+thrashing|context\s+refilled\s+to\s+the\s+limit|refilled\s+to\s+the\s+limit\s+within/i.test(text);
|
|
179
|
+
}
|
|
180
|
+
export function contextThrashRecoveryNotice() {
|
|
181
|
+
return [
|
|
182
|
+
'I hit a context-size recovery issue while working on that.',
|
|
183
|
+
'I saved the request and reset the session so I can continue with smaller reads instead of repeating the same large-output path.',
|
|
184
|
+
].join(' ');
|
|
185
|
+
}
|
|
186
|
+
export function buildContextThrashRecoveryPrompt(userRequest, priorFailureText = '') {
|
|
187
|
+
const parts = [
|
|
188
|
+
'[CONTEXT-THRASH RECOVERY]',
|
|
189
|
+
'',
|
|
190
|
+
'The previous interactive attempt failed because tool output filled the context window and SDK autocompact thrashed. Continue the user request, but use a small diagnostic pass.',
|
|
191
|
+
'',
|
|
192
|
+
'User request:',
|
|
193
|
+
userRequest,
|
|
194
|
+
'',
|
|
195
|
+
'Recovery rules:',
|
|
196
|
+
'- Do not repeat broad reads, full log dumps, full JSON dumps, or unbounded API/list commands.',
|
|
197
|
+
'- Prefer status files, summaries, indexes, `rg`, `tail -80`, `head -80`, and `sed -n` slices.',
|
|
198
|
+
'- For cron or unleashed jobs, inspect only `status.json`, the tail of `progress.jsonl`, and the latest run preview first. Do not read full run logs unless a short slice identifies the exact file and range.',
|
|
199
|
+
'- Preserve the user intent. Identify what failed, what you changed or verified, and the next action.',
|
|
200
|
+
'- Finish with `TASK_COMPLETE:` followed by a concise user-facing summary.',
|
|
201
|
+
];
|
|
202
|
+
if (priorFailureText.trim()) {
|
|
203
|
+
parts.push('', 'Prior failure excerpt:', priorFailureText.trim().slice(0, 1200));
|
|
204
|
+
}
|
|
205
|
+
return parts.join('\n');
|
|
206
|
+
}
|
|
175
207
|
/**
|
|
176
208
|
* Strip lone Unicode surrogates (U+D800–U+DFFF) from a string so it can be
|
|
177
209
|
* safely serialized to JSON. Lone surrogates are valid in JS strings but
|
|
@@ -640,6 +672,27 @@ function yesterdayISO() {
|
|
|
640
672
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
641
673
|
}
|
|
642
674
|
// ── Cron Output Extraction ──────────────────────────────────────────
|
|
675
|
+
/** Autonomous jobs use this sentinel to mean "completed, but do not notify the owner." */
|
|
676
|
+
export function isAutonomousNothingOutput(response) {
|
|
677
|
+
const trimmed = response.trim();
|
|
678
|
+
if (!trimmed)
|
|
679
|
+
return false;
|
|
680
|
+
if (trimmed === '__NOTHING__')
|
|
681
|
+
return true;
|
|
682
|
+
if (/^_*NOTHING_*$/i.test(trimmed))
|
|
683
|
+
return true;
|
|
684
|
+
if (/^_*NOTHING_*\s*(\(|$)/im.test(trimmed))
|
|
685
|
+
return true;
|
|
686
|
+
if (/^(_*NOTHING_*\s*)?\[MONITORING\]\s*$/i.test(trimmed))
|
|
687
|
+
return true;
|
|
688
|
+
if (trimmed.length > 80)
|
|
689
|
+
return false;
|
|
690
|
+
const lower = trimmed.toLowerCase();
|
|
691
|
+
return lower === 'nothing to report'
|
|
692
|
+
|| lower === 'nothing new to report'
|
|
693
|
+
|| lower === 'no updates'
|
|
694
|
+
|| lower === 'all clear';
|
|
695
|
+
}
|
|
643
696
|
/** Return the last non-empty text block that came after the last tool call, or '' if nothing/sentinel. */
|
|
644
697
|
function extractDeliverable(trace) {
|
|
645
698
|
if (trace.length === 0)
|
|
@@ -657,7 +710,7 @@ function extractDeliverable(trace) {
|
|
|
657
710
|
for (let i = trace.length - 1; i > lastToolIdx; i--) {
|
|
658
711
|
if (trace[i].type === 'text') {
|
|
659
712
|
const text = trace[i].content.trim();
|
|
660
|
-
if (text
|
|
713
|
+
if (isAutonomousNothingOutput(text))
|
|
661
714
|
return '';
|
|
662
715
|
if (text.length > 0)
|
|
663
716
|
return text;
|
|
@@ -1609,6 +1662,54 @@ Never spawn a sub-agent with vague instructions like "handle this brief."
|
|
|
1609
1662
|
|
|
1610
1663
|
When ${owner} expresses satisfaction ("nice", "perfect", "great job", "thanks") or dissatisfaction ("no", "wrong", "that's not right", "ugh"), call \`feedback_log\` with an appropriate rating ('positive' or 'negative') and a brief comment summarizing the context. This helps me learn from interactions.`);
|
|
1611
1664
|
}
|
|
1665
|
+
try {
|
|
1666
|
+
const jsonExperience = loadClementineJson(BASE_DIR).assistant ?? {};
|
|
1667
|
+
const pick = (value, allowed) => allowed.includes(value) ? value : undefined;
|
|
1668
|
+
const experience = {
|
|
1669
|
+
proactivity: pick(process.env.ASSISTANT_PROACTIVITY, ['quiet', 'balanced', 'proactive', 'operator']) ?? jsonExperience.proactivity,
|
|
1670
|
+
responseStyle: pick(process.env.ASSISTANT_RESPONSE_STYLE, ['concise', 'balanced', 'detailed']) ?? jsonExperience.responseStyle,
|
|
1671
|
+
progressVisibility: pick(process.env.ASSISTANT_PROGRESS_VISIBILITY, ['quiet', 'normal', 'detailed']) ?? jsonExperience.progressVisibility,
|
|
1672
|
+
autonomy: pick(process.env.ASSISTANT_AUTONOMY, ['ask_first', 'balanced', 'act_when_safe']) ?? jsonExperience.autonomy,
|
|
1673
|
+
};
|
|
1674
|
+
const lines = [];
|
|
1675
|
+
if (experience.proactivity) {
|
|
1676
|
+
const guidance = {
|
|
1677
|
+
quiet: 'Only interrupt for urgent or explicitly requested work. Avoid unsolicited next steps.',
|
|
1678
|
+
balanced: 'Offer useful next steps when natural, but do not create extra work without a clear reason.',
|
|
1679
|
+
proactive: 'Surface likely next actions, risks, and background-work opportunities before the owner has to ask.',
|
|
1680
|
+
operator: 'Operate forward: propose plans, queue safe background work, monitor progress, and keep the owner informed.',
|
|
1681
|
+
};
|
|
1682
|
+
lines.push(`- Proactivity: ${experience.proactivity}. ${guidance[experience.proactivity]}`);
|
|
1683
|
+
}
|
|
1684
|
+
if (experience.responseStyle) {
|
|
1685
|
+
const guidance = {
|
|
1686
|
+
concise: 'Default to short, direct answers. Expand only when the task needs it.',
|
|
1687
|
+
balanced: 'Match detail to task complexity.',
|
|
1688
|
+
detailed: 'Include more reasoning, context, and verification detail for substantive work.',
|
|
1689
|
+
};
|
|
1690
|
+
lines.push(`- Response style: ${experience.responseStyle}. ${guidance[experience.responseStyle]}`);
|
|
1691
|
+
}
|
|
1692
|
+
if (experience.progressVisibility) {
|
|
1693
|
+
const guidance = {
|
|
1694
|
+
quiet: 'Minimize process narration unless work is slow, blocked, or risky.',
|
|
1695
|
+
normal: 'Share important progress and decision points.',
|
|
1696
|
+
detailed: 'Keep the owner posted during background or multi-tool work, including failures and recoveries.',
|
|
1697
|
+
};
|
|
1698
|
+
lines.push(`- Progress visibility: ${experience.progressVisibility}. ${guidance[experience.progressVisibility]}`);
|
|
1699
|
+
}
|
|
1700
|
+
if (experience.autonomy) {
|
|
1701
|
+
const guidance = {
|
|
1702
|
+
ask_first: 'Ask before taking actions that change external systems or user data.',
|
|
1703
|
+
balanced: 'Act on low-risk reversible steps; ask on irreversible, costly, or ambiguous steps.',
|
|
1704
|
+
act_when_safe: 'Use judgment and proceed on safe, reversible, clearly beneficial work.',
|
|
1705
|
+
};
|
|
1706
|
+
lines.push(`- Autonomy: ${experience.autonomy}. ${guidance[experience.autonomy]}`);
|
|
1707
|
+
}
|
|
1708
|
+
if (lines.length > 0) {
|
|
1709
|
+
parts.push(`## Owner Experience Preferences\n\n${lines.join('\n')}`);
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
catch { /* config preferences are optional */ }
|
|
1612
1713
|
// Verbose level overrides
|
|
1613
1714
|
if (verboseLevel === 'quiet') {
|
|
1614
1715
|
parts.push(`## Verbosity: Quiet\n\nGive results directly. Skip reasoning and progress updates unless asked.`);
|
|
@@ -1858,11 +1959,18 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1858
1959
|
reason: bundles.length > 0 ? 'matched' : 'empty',
|
|
1859
1960
|
};
|
|
1860
1961
|
};
|
|
1962
|
+
const autonomousToolRun = isHeartbeat || isCron || isPlanStep || isUnleashed;
|
|
1861
1963
|
const promptToolRoute = routeToolSurface(promptScopeText);
|
|
1862
1964
|
const profileToolRoute = routeToolSurface(profileScopeText);
|
|
1863
1965
|
const contextToolRoute = routeToolSurface(contextRoutingText);
|
|
1966
|
+
const promptHasToolRoute = promptToolRoute.fullSurface || promptToolRoute.bundles.length > 0;
|
|
1967
|
+
const directFollowupNeedsContextTools = intentClassification?.type === 'followup'
|
|
1968
|
+
|| /^(yes|yep|yeah|go|go ahead|do it|continue|pick up|use that|run it|send it|same thing)\b/i.test(promptScopeText.trim());
|
|
1969
|
+
const allowContextToolRoute = autonomousToolRun || (!promptHasToolRoute && directFollowupNeedsContextTools);
|
|
1864
1970
|
const safeProfileToolRoute = profileToolRoute.fullSurface ? emptyToolRoute() : profileToolRoute;
|
|
1865
|
-
const safeContextToolRoute = contextToolRoute.fullSurface
|
|
1971
|
+
const safeContextToolRoute = allowContextToolRoute && !contextToolRoute.fullSurface
|
|
1972
|
+
? contextToolRoute
|
|
1973
|
+
: emptyToolRoute();
|
|
1866
1974
|
const toolRoute = mergeToolRoutes(promptToolRoute, mergeToolRoutes(safeProfileToolRoute, safeContextToolRoute));
|
|
1867
1975
|
let allowedTools = [];
|
|
1868
1976
|
const addAllowed = (...tools) => {
|
|
@@ -1876,10 +1984,9 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1876
1984
|
};
|
|
1877
1985
|
const scopeText = [
|
|
1878
1986
|
directScopeText,
|
|
1879
|
-
contextRoutingText,
|
|
1987
|
+
allowContextToolRoute ? contextRoutingText : '',
|
|
1880
1988
|
].filter(Boolean).join('\n').toLowerCase();
|
|
1881
1989
|
const promptScopeLower = promptScopeText.toLowerCase();
|
|
1882
|
-
const autonomousToolRun = isHeartbeat || isCron || isPlanStep || isUnleashed;
|
|
1883
1990
|
const taskIntent = intentClassification?.type === 'task' || autonomousToolRun;
|
|
1884
1991
|
const memoryNeeded = autonomousToolRun
|
|
1885
1992
|
|| retrievalContext.trim().length > 0
|
|
@@ -2667,8 +2774,22 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
2667
2774
|
}
|
|
2668
2775
|
// Lone-surrogate sanitization happens at the SDK boundary (see query() wrapper).
|
|
2669
2776
|
let effectivePrompt = text;
|
|
2777
|
+
const recentExchangesForIntent = key ? this.lastExchanges.get(key) : undefined;
|
|
2778
|
+
const intent = classifyIntent(text, recentExchangesForIntent);
|
|
2779
|
+
const turnPolicy = decideTurnPolicy({
|
|
2780
|
+
text,
|
|
2781
|
+
intent,
|
|
2782
|
+
hasRecentContext: !!(recentExchangesForIntent?.length || (key && this.sessions.has(key))),
|
|
2783
|
+
});
|
|
2784
|
+
const suppressContextInjection = turnPolicy.suppressContextInjection === true;
|
|
2785
|
+
if (key && turnPolicy.suppressSessionResume) {
|
|
2786
|
+
this.sessions.delete(key);
|
|
2787
|
+
this.exchangeCounts.set(key, 0);
|
|
2788
|
+
this.restoredSessions.delete(key);
|
|
2789
|
+
this._compactedSessions.delete(key);
|
|
2790
|
+
}
|
|
2670
2791
|
// If session rotated, use instant local summary + handoff + kick off LLM summary in background
|
|
2671
|
-
if (sessionRotated && key) {
|
|
2792
|
+
if (sessionRotated && key && !suppressContextInjection) {
|
|
2672
2793
|
const summary = this.buildLocalSummary(key);
|
|
2673
2794
|
const handoff = this.loadHandoff(key);
|
|
2674
2795
|
const contextParts = [];
|
|
@@ -2687,7 +2808,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
2687
2808
|
this.summarizeSessionAsync(key).catch(err => logger.debug({ err, key }, 'Session summarization failed'));
|
|
2688
2809
|
}
|
|
2689
2810
|
// Resilience: inject exchange history if no session_id stored
|
|
2690
|
-
if (key && !this.sessions.has(key) && !sessionRotated) {
|
|
2811
|
+
if (key && !suppressContextInjection && !this.sessions.has(key) && !sessionRotated) {
|
|
2691
2812
|
const exchanges = this.lastExchanges.get(key) ?? [];
|
|
2692
2813
|
if (exchanges.length > 0) {
|
|
2693
2814
|
const historyLines = [];
|
|
@@ -2700,7 +2821,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
2700
2821
|
}
|
|
2701
2822
|
}
|
|
2702
2823
|
// Inject context on first message after a daemon restart (session restored from disk)
|
|
2703
|
-
if (key && this.restoredSessions.has(key)) {
|
|
2824
|
+
if (key && !suppressContextInjection && this.restoredSessions.has(key)) {
|
|
2704
2825
|
const exchanges = this.lastExchanges.get(key) ?? [];
|
|
2705
2826
|
if (exchanges.length > 0) {
|
|
2706
2827
|
const olderSummary = this.buildOlderTurnsContext(key, exchanges);
|
|
@@ -2720,7 +2841,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
2720
2841
|
this.restoredSessions.delete(key); // Only inject once per restored session
|
|
2721
2842
|
}
|
|
2722
2843
|
// Fresh session with no history — inject last conversation context
|
|
2723
|
-
if (key && !sessionRotated && !this.restoredSessions.has(key)) {
|
|
2844
|
+
if (key && !suppressContextInjection && !sessionRotated && !this.restoredSessions.has(key)) {
|
|
2724
2845
|
const exchanges = this.lastExchanges.get(key) ?? [];
|
|
2725
2846
|
if (exchanges.length === 0 && this.memoryStore) {
|
|
2726
2847
|
try {
|
|
@@ -2741,7 +2862,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
2741
2862
|
}
|
|
2742
2863
|
}
|
|
2743
2864
|
// Time-gap awareness: let the agent know how long it's been
|
|
2744
|
-
if (key && this.sessionTimestamps.has(key)) {
|
|
2865
|
+
if (key && !suppressContextInjection && this.sessionTimestamps.has(key)) {
|
|
2745
2866
|
const gapMs = Date.now() - this.sessionTimestamps.get(key).getTime();
|
|
2746
2867
|
const gapHours = Math.round(gapMs / 3_600_000);
|
|
2747
2868
|
if (gapHours >= 8) {
|
|
@@ -2753,7 +2874,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
2753
2874
|
// injectContext uses the base session key (e.g. discord:user:123) but
|
|
2754
2875
|
// chat may use a profile-suffixed key (discord:user:123:sales-agent),
|
|
2755
2876
|
// so also check any pending key that the current key starts with.
|
|
2756
|
-
if (key) {
|
|
2877
|
+
if (key && !suppressContextInjection) {
|
|
2757
2878
|
const allPending = [];
|
|
2758
2879
|
for (const [pendingKey, pending] of this.pendingContext) {
|
|
2759
2880
|
if (key === pendingKey || key.startsWith(pendingKey + ':')) {
|
|
@@ -2771,7 +2892,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
2771
2892
|
}
|
|
2772
2893
|
}
|
|
2773
2894
|
// Inject stall nudge if the previous query for this session showed stall signals
|
|
2774
|
-
if (key && this.stallNudges.has(key)) {
|
|
2895
|
+
if (key && !suppressContextInjection && this.stallNudges.has(key)) {
|
|
2775
2896
|
const nudge = this.stallNudges.get(key);
|
|
2776
2897
|
this.stallNudges.delete(key);
|
|
2777
2898
|
effectivePrompt =
|
|
@@ -2780,16 +2901,6 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
2780
2901
|
`Either take the action NOW using your tools, or tell the user exactly what is blocking you. ` +
|
|
2781
2902
|
`If a file can't be read, say so. If you're stuck, say so. Never stall silently.]\n\n${effectivePrompt}`;
|
|
2782
2903
|
}
|
|
2783
|
-
// ── Intent classification ─────────────────────────────────────
|
|
2784
|
-
// Classify intent before the main query to dynamically tune response
|
|
2785
|
-
// strategy, maxTurns, and effort level
|
|
2786
|
-
const recentExchanges = key ? this.lastExchanges.get(key) : undefined;
|
|
2787
|
-
const intent = classifyIntent(text, recentExchanges);
|
|
2788
|
-
const turnPolicy = decideTurnPolicy({
|
|
2789
|
-
text,
|
|
2790
|
-
intent,
|
|
2791
|
-
hasRecentContext: !!(recentExchanges?.length || (key && this.sessions.has(key))),
|
|
2792
|
-
});
|
|
2793
2904
|
logger.debug({
|
|
2794
2905
|
intent: intent.type,
|
|
2795
2906
|
confidence: intent.confidence,
|
|
@@ -2833,7 +2944,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
2833
2944
|
if (key && !isApiError) {
|
|
2834
2945
|
this.exchangeCounts.set(key, (this.exchangeCounts.get(key) ?? 0) + 1);
|
|
2835
2946
|
this.sessionTimestamps.set(key, new Date());
|
|
2836
|
-
const history = this.lastExchanges.get(key) ?? [];
|
|
2947
|
+
const history = turnPolicy.suppressContextInjection ? [] : (this.lastExchanges.get(key) ?? []);
|
|
2837
2948
|
history.push({ user: text, assistant: responseText });
|
|
2838
2949
|
if (history.length > SESSION_EXCHANGE_HISTORY_SIZE) {
|
|
2839
2950
|
this.lastExchanges.set(key, history.slice(-SESSION_EXCHANGE_HISTORY_SIZE));
|
|
@@ -3005,6 +3116,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3005
3116
|
// Flipped true on the first intervention; subsequent replies go through
|
|
3006
3117
|
// un-validated (but still logged).
|
|
3007
3118
|
let contradictionRetried = false;
|
|
3119
|
+
let contextRecoveryRetries = 0;
|
|
3008
3120
|
try {
|
|
3009
3121
|
for (let attempt = 0; attempt <= PersonalAssistant.RATE_LIMIT_MAX_RETRIES; attempt++) {
|
|
3010
3122
|
const sdkOptions = await this.buildOptions({
|
|
@@ -3034,7 +3146,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3034
3146
|
sdkOptions.cwd = matchedProject.path;
|
|
3035
3147
|
}
|
|
3036
3148
|
// Set resume session if available
|
|
3037
|
-
if (sessionKey && this.sessions.has(sessionKey)) {
|
|
3149
|
+
if (sessionKey && this.sessions.has(sessionKey) && !effectiveTurnPolicy?.suppressSessionResume) {
|
|
3038
3150
|
sdkOptions.resume = this.sessions.get(sessionKey);
|
|
3039
3151
|
}
|
|
3040
3152
|
// Context window guard: estimate token usage and bail if too tight.
|
|
@@ -3257,7 +3369,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3257
3369
|
// Auth errors — throw so the gateway circuit breaker catches it
|
|
3258
3370
|
throw new Error(errorText);
|
|
3259
3371
|
}
|
|
3260
|
-
else if (
|
|
3372
|
+
else if (looksLikeContextThrashText(errorText)) {
|
|
3261
3373
|
// Autocompact thrashing — treat like the exception path
|
|
3262
3374
|
logger.warn({ sessionKey }, 'Autocompact thrashing (result error) — will rotate session');
|
|
3263
3375
|
// Capture mid-task state BEFORE rotating, so the retry
|
|
@@ -3297,6 +3409,25 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3297
3409
|
else if ('result' in result && result.result) {
|
|
3298
3410
|
// Success: use SDK result text if streaming didn't capture a substantive response
|
|
3299
3411
|
const sdkResult = result.result;
|
|
3412
|
+
if (looksLikeContextThrashText(sdkResult)) {
|
|
3413
|
+
logger.warn({ sessionKey }, 'Autocompact thrashing surfaced as SDK result text — rotating session');
|
|
3414
|
+
preRotationSnapshot = {
|
|
3415
|
+
toolCalls: stallGuard?.getToolCalls() ?? [],
|
|
3416
|
+
partialText: responseText.slice(-1000),
|
|
3417
|
+
};
|
|
3418
|
+
if (sessionKey) {
|
|
3419
|
+
try {
|
|
3420
|
+
this.compactContext(sessionKey);
|
|
3421
|
+
}
|
|
3422
|
+
catch { /* best-effort */ }
|
|
3423
|
+
this.sessions.delete(sessionKey);
|
|
3424
|
+
this.exchangeCounts.set(sessionKey, 0);
|
|
3425
|
+
this._compactedSessions.delete(sessionKey);
|
|
3426
|
+
}
|
|
3427
|
+
staleSession = true;
|
|
3428
|
+
contextRecovery = true;
|
|
3429
|
+
break;
|
|
3430
|
+
}
|
|
3300
3431
|
logger.info({ sessionKey, streamedLen: responseText.length, resultLen: sdkResult.length }, 'SDK result text available');
|
|
3301
3432
|
if (!responseText.trim()) {
|
|
3302
3433
|
responseText = sdkResult;
|
|
@@ -3358,7 +3489,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3358
3489
|
}
|
|
3359
3490
|
}
|
|
3360
3491
|
}
|
|
3361
|
-
else if (
|
|
3492
|
+
else if (looksLikeContextThrashText(e)) {
|
|
3362
3493
|
// SDK autocompact thrashing — tool outputs are too large for the context window.
|
|
3363
3494
|
// Rotate session and retry with a fresh context so the agent can continue.
|
|
3364
3495
|
logger.warn({ sessionKey }, 'Autocompact thrashing — rotating session and retrying');
|
|
@@ -3377,13 +3508,14 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3377
3508
|
this.exchangeCounts.set(sessionKey, 0);
|
|
3378
3509
|
this._compactedSessions.delete(sessionKey);
|
|
3379
3510
|
}
|
|
3380
|
-
if (attempt < PersonalAssistant.RATE_LIMIT_MAX_RETRIES) {
|
|
3511
|
+
if (attempt < PersonalAssistant.RATE_LIMIT_MAX_RETRIES && contextRecoveryRetries < 1) {
|
|
3512
|
+
contextRecoveryRetries++;
|
|
3381
3513
|
prompt = buildContextRecoveredPrompt(prompt, preRotationSnapshot);
|
|
3382
3514
|
preRotationSnapshot = null;
|
|
3383
3515
|
responseText = '';
|
|
3384
3516
|
continue;
|
|
3385
3517
|
}
|
|
3386
|
-
responseText = responseText ||
|
|
3518
|
+
responseText = responseText || contextThrashRecoveryNotice();
|
|
3387
3519
|
}
|
|
3388
3520
|
else if (errStr.includes('prompt is too long') || errStr.includes('prompt too long') || errStr.includes('context_length')) {
|
|
3389
3521
|
responseText = responseText || ('The conversation got too large to process (tool responses filled the context window). ' +
|
|
@@ -3430,11 +3562,25 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3430
3562
|
if (staleSession && attempt < PersonalAssistant.RATE_LIMIT_MAX_RETRIES) {
|
|
3431
3563
|
responseText = '';
|
|
3432
3564
|
if (contextRecovery) {
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3565
|
+
if (contextRecoveryRetries >= 1) {
|
|
3566
|
+
responseText = contextThrashRecoveryNotice();
|
|
3567
|
+
staleSession = false;
|
|
3568
|
+
contextRecovery = false;
|
|
3569
|
+
}
|
|
3570
|
+
else {
|
|
3571
|
+
contextRecoveryRetries++;
|
|
3572
|
+
prompt = buildContextRecoveredPrompt(prompt, preRotationSnapshot);
|
|
3573
|
+
preRotationSnapshot = null;
|
|
3574
|
+
contextRecovery = false;
|
|
3575
|
+
continue;
|
|
3576
|
+
}
|
|
3436
3577
|
}
|
|
3437
|
-
|
|
3578
|
+
else {
|
|
3579
|
+
continue;
|
|
3580
|
+
}
|
|
3581
|
+
}
|
|
3582
|
+
if (staleSession && contextRecovery && !responseText.trim()) {
|
|
3583
|
+
responseText = contextThrashRecoveryNotice();
|
|
3438
3584
|
}
|
|
3439
3585
|
if (hitRateLimit && attempt < PersonalAssistant.RATE_LIMIT_MAX_RETRIES) {
|
|
3440
3586
|
const base = rateLimitRetryAfterMs
|
|
@@ -3450,6 +3596,26 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
3450
3596
|
if (hitRateLimit && !responseText) {
|
|
3451
3597
|
responseText = "I'm being rate limited right now. Give me a minute and try again.";
|
|
3452
3598
|
}
|
|
3599
|
+
if (looksLikeContextThrashText(responseText)) {
|
|
3600
|
+
logger.warn({ sessionKey }, 'Autocompact thrashing escaped into response text — rotating session before reply');
|
|
3601
|
+
if (sessionKey) {
|
|
3602
|
+
try {
|
|
3603
|
+
this.compactContext(sessionKey);
|
|
3604
|
+
}
|
|
3605
|
+
catch { /* best-effort */ }
|
|
3606
|
+
this.sessions.delete(sessionKey);
|
|
3607
|
+
this.exchangeCounts.set(sessionKey, 0);
|
|
3608
|
+
this._compactedSessions.delete(sessionKey);
|
|
3609
|
+
}
|
|
3610
|
+
if (attempt < PersonalAssistant.RATE_LIMIT_MAX_RETRIES && contextRecoveryRetries < 1) {
|
|
3611
|
+
contextRecoveryRetries++;
|
|
3612
|
+
prompt = buildContextRecoveredPrompt(prompt, preRotationSnapshot);
|
|
3613
|
+
preRotationSnapshot = null;
|
|
3614
|
+
responseText = '';
|
|
3615
|
+
continue;
|
|
3616
|
+
}
|
|
3617
|
+
responseText = contextThrashRecoveryNotice();
|
|
3618
|
+
}
|
|
3453
3619
|
// ── Response guarantee ─────────────────────────────────────────
|
|
3454
3620
|
// The model often generates 30+ tool calls with minimal/no text. Ensure
|
|
3455
3621
|
// the user always gets a substantive response after real work is done.
|
|
@@ -4894,7 +5060,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
4894
5060
|
if (cronGuard) {
|
|
4895
5061
|
const summary = cronGuard.getSummary();
|
|
4896
5062
|
const mc = summary.metacognition;
|
|
4897
|
-
if (mc.confidenceFinal === 'low' && deliverable && deliverable
|
|
5063
|
+
if (mc.confidenceFinal === 'low' && deliverable && !isAutonomousNothingOutput(deliverable)) {
|
|
4898
5064
|
try {
|
|
4899
5065
|
const escalationsFile = path.join(BASE_DIR, 'escalations.json');
|
|
4900
5066
|
const escalations = fs.existsSync(escalationsFile)
|
|
@@ -5425,6 +5591,21 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
5425
5591
|
lastPhaseOutputPreview: lastOutput.slice(0, 300),
|
|
5426
5592
|
});
|
|
5427
5593
|
logger.info(`Unleashed task ${jobName}: phase ${phase} complete (${(phaseDurationMs / 1000).toFixed(0)}s)`);
|
|
5594
|
+
// The job explicitly says there is nothing to report. Treat that as a
|
|
5595
|
+
// clean terminal state instead of resuming the same no-op phase until
|
|
5596
|
+
// the max-phase guard fires.
|
|
5597
|
+
if (isAutonomousNothingOutput(lastOutput)) {
|
|
5598
|
+
appendProgress({ event: 'completed_silent', phase });
|
|
5599
|
+
writeStatus({ jobName, status: 'completed', phase, startedAt, finishedAt: new Date().toISOString(), silent: true });
|
|
5600
|
+
logger.info(`Unleashed task ${jobName} completed silently at phase ${phase}`);
|
|
5601
|
+
if (this.onUnleashedComplete) {
|
|
5602
|
+
try {
|
|
5603
|
+
this.onUnleashedComplete(jobName, '__NOTHING__');
|
|
5604
|
+
}
|
|
5605
|
+
catch { /* non-fatal */ }
|
|
5606
|
+
}
|
|
5607
|
+
return '__NOTHING__';
|
|
5608
|
+
}
|
|
5428
5609
|
// Notify phase progress callback
|
|
5429
5610
|
if (this.onPhaseComplete) {
|
|
5430
5611
|
try {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ClementineJson } from '../config/clementine-json.js';
|
|
2
|
+
export type ProactivityMode = 'quiet' | 'balanced' | 'proactive' | 'operator';
|
|
3
|
+
export type ResponseStyle = 'concise' | 'balanced' | 'detailed';
|
|
4
|
+
export type ProgressVisibility = 'quiet' | 'normal' | 'detailed';
|
|
5
|
+
export type AutonomyMode = 'ask_first' | 'balanced' | 'act_when_safe';
|
|
6
|
+
export interface AssistantExperienceUpdate {
|
|
7
|
+
proactivity?: ProactivityMode;
|
|
8
|
+
responseStyle?: ResponseStyle;
|
|
9
|
+
progressVisibility?: ProgressVisibility;
|
|
10
|
+
autonomy?: AutonomyMode;
|
|
11
|
+
}
|
|
12
|
+
export type LocalTurnIntent = {
|
|
13
|
+
kind: 'none';
|
|
14
|
+
} | {
|
|
15
|
+
kind: 'ack';
|
|
16
|
+
} | {
|
|
17
|
+
kind: 'greeting';
|
|
18
|
+
} | {
|
|
19
|
+
kind: 'stop';
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'status';
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'preference_update';
|
|
24
|
+
updates: AssistantExperienceUpdate;
|
|
25
|
+
summary: string;
|
|
26
|
+
};
|
|
27
|
+
export declare function isStopRequest(text: string): boolean;
|
|
28
|
+
export declare function isStatusRequest(text: string): boolean;
|
|
29
|
+
export declare function isTinyAcknowledgment(text: string): boolean;
|
|
30
|
+
export declare function detectLocalTurn(text: string): LocalTurnIntent;
|
|
31
|
+
export declare function applyAssistantExperienceUpdate(cfg: ClementineJson, updates: AssistantExperienceUpdate): ClementineJson;
|
|
32
|
+
//# sourceMappingURL=local-turn.d.ts.map
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { isStandaloneGreeting } from './turn-policy.js';
|
|
2
|
+
function normalize(text) {
|
|
3
|
+
return text
|
|
4
|
+
.trim()
|
|
5
|
+
.toLowerCase()
|
|
6
|
+
.replace(/[.!?]+$/g, '')
|
|
7
|
+
.replace(/\s+/g, ' ');
|
|
8
|
+
}
|
|
9
|
+
function wordCount(text) {
|
|
10
|
+
const t = text.trim();
|
|
11
|
+
return t ? t.split(/\s+/).length : 0;
|
|
12
|
+
}
|
|
13
|
+
export function isStopRequest(text) {
|
|
14
|
+
const n = normalize(text);
|
|
15
|
+
if (wordCount(n) > 5)
|
|
16
|
+
return false;
|
|
17
|
+
return /^(stop|cancel|abort|halt|pause|nevermind|never mind|wait stop|stop please|cancel that|stop that)$/.test(n);
|
|
18
|
+
}
|
|
19
|
+
export function isStatusRequest(text) {
|
|
20
|
+
const n = normalize(text);
|
|
21
|
+
if (wordCount(n) > 8)
|
|
22
|
+
return false;
|
|
23
|
+
return /^(status|task status|deep status|progress|what'?s happening|what'?s going on|what are you doing|are you working|anything running|what'?s running|background status|check status|where are we)$/.test(n);
|
|
24
|
+
}
|
|
25
|
+
export function isTinyAcknowledgment(text) {
|
|
26
|
+
const n = normalize(text);
|
|
27
|
+
if (wordCount(n) > 4)
|
|
28
|
+
return false;
|
|
29
|
+
return /^(thanks|thank you|thx|ty|nice|great|perfect|awesome|cool|ok|okay|sounds good|got it|makes sense|love it)$/.test(n);
|
|
30
|
+
}
|
|
31
|
+
function parseProactivity(text) {
|
|
32
|
+
if (/\b(operator mode|operator)\b/i.test(text))
|
|
33
|
+
return 'operator';
|
|
34
|
+
if (/\b(more proactive|be proactive|proactive mode|set proactivity to proactive)\b/i.test(text))
|
|
35
|
+
return 'proactive';
|
|
36
|
+
if (/\b(less proactive|quieter|quiet mode|be quiet|only urgent|do not interrupt)\b/i.test(text))
|
|
37
|
+
return 'quiet';
|
|
38
|
+
if (/\b(balanced proactivity|balanced mode|normal proactivity)\b/i.test(text))
|
|
39
|
+
return 'balanced';
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
function parseResponseStyle(text) {
|
|
43
|
+
if (/\b(be concise|keep it concise|shorter replies|brief replies|reply briefly|less verbose)\b/i.test(text))
|
|
44
|
+
return 'concise';
|
|
45
|
+
if (/\b(more detail|detailed replies|be detailed|explain more|more verbose)\b/i.test(text))
|
|
46
|
+
return 'detailed';
|
|
47
|
+
if (/\b(balanced replies|normal replies|balanced detail)\b/i.test(text))
|
|
48
|
+
return 'balanced';
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
function parseProgressVisibility(text) {
|
|
52
|
+
if (/\b(show more progress|keep me posted|more updates|detailed progress|tell me what'?s happening)\b/i.test(text))
|
|
53
|
+
return 'detailed';
|
|
54
|
+
if (/\b(less progress|fewer updates|quiet progress|don'?t narrate)\b/i.test(text))
|
|
55
|
+
return 'quiet';
|
|
56
|
+
if (/\b(normal progress|balanced progress)\b/i.test(text))
|
|
57
|
+
return 'normal';
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
function parseAutonomy(text) {
|
|
61
|
+
if (/\b(ask first|ask me first|ask before acting|do not act without asking)\b/i.test(text))
|
|
62
|
+
return 'ask_first';
|
|
63
|
+
if (/\b(act when safe|more autonomous|use your judgment|handle it when safe)\b/i.test(text))
|
|
64
|
+
return 'act_when_safe';
|
|
65
|
+
if (/\b(balanced autonomy|normal autonomy)\b/i.test(text))
|
|
66
|
+
return 'balanced';
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
export function detectLocalTurn(text) {
|
|
70
|
+
if (isStopRequest(text))
|
|
71
|
+
return { kind: 'stop' };
|
|
72
|
+
if (isStatusRequest(text))
|
|
73
|
+
return { kind: 'status' };
|
|
74
|
+
if (isStandaloneGreeting(text))
|
|
75
|
+
return { kind: 'greeting' };
|
|
76
|
+
if (isTinyAcknowledgment(text))
|
|
77
|
+
return { kind: 'ack' };
|
|
78
|
+
const updates = {};
|
|
79
|
+
const proactivity = parseProactivity(text);
|
|
80
|
+
const responseStyle = parseResponseStyle(text);
|
|
81
|
+
const progressVisibility = parseProgressVisibility(text);
|
|
82
|
+
const autonomy = parseAutonomy(text);
|
|
83
|
+
if (proactivity)
|
|
84
|
+
updates.proactivity = proactivity;
|
|
85
|
+
if (responseStyle)
|
|
86
|
+
updates.responseStyle = responseStyle;
|
|
87
|
+
if (progressVisibility)
|
|
88
|
+
updates.progressVisibility = progressVisibility;
|
|
89
|
+
if (autonomy)
|
|
90
|
+
updates.autonomy = autonomy;
|
|
91
|
+
const entries = Object.entries(updates);
|
|
92
|
+
if (entries.length === 0)
|
|
93
|
+
return { kind: 'none' };
|
|
94
|
+
const summary = entries.map(([k, v]) => `${k}: ${v}`).join(', ');
|
|
95
|
+
return { kind: 'preference_update', updates, summary };
|
|
96
|
+
}
|
|
97
|
+
export function applyAssistantExperienceUpdate(cfg, updates) {
|
|
98
|
+
return {
|
|
99
|
+
...cfg,
|
|
100
|
+
schemaVersion: 1,
|
|
101
|
+
assistant: {
|
|
102
|
+
...(cfg.assistant ?? {}),
|
|
103
|
+
...updates,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=local-turn.js.map
|
|
@@ -76,6 +76,7 @@ const PATTERNS = [
|
|
|
76
76
|
recipe: () => ({
|
|
77
77
|
category: 'safe-cron-config',
|
|
78
78
|
description: 'Context window blowing up mid-run. Switching to unleashed mode so each phase starts with a fresh context.',
|
|
79
|
+
fields: ['mode', 'max_hours'],
|
|
79
80
|
apply: (job) => {
|
|
80
81
|
let changed = false;
|
|
81
82
|
if (job.mode !== 'unleashed') {
|
|
@@ -15,6 +15,10 @@ export interface TurnPolicy {
|
|
|
15
15
|
effort: 'low' | 'medium' | 'high';
|
|
16
16
|
allowProactiveGoals: boolean;
|
|
17
17
|
fetchLinks: boolean;
|
|
18
|
+
/** Do not resume the prior Claude SDK session for this turn. */
|
|
19
|
+
suppressSessionResume?: boolean;
|
|
20
|
+
/** Do not inject restored/pending/background context for this turn. */
|
|
21
|
+
suppressContextInjection?: boolean;
|
|
18
22
|
reason: string;
|
|
19
23
|
}
|
|
20
24
|
export interface TurnPolicyInput {
|
|
@@ -23,5 +27,6 @@ export interface TurnPolicyInput {
|
|
|
23
27
|
hasRecentContext: boolean;
|
|
24
28
|
isAutonomous?: boolean;
|
|
25
29
|
}
|
|
30
|
+
export declare function isStandaloneGreeting(text: string): boolean;
|
|
26
31
|
export declare function decideTurnPolicy(input: TurnPolicyInput): TurnPolicy;
|
|
27
32
|
//# sourceMappingURL=turn-policy.d.ts.map
|