clementine-agent 1.18.49 → 1.18.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/agent-definitions.js +49 -8
- package/dist/agent/assistant.d.ts +0 -10
- package/dist/agent/assistant.js +0 -83
- package/dist/agent/route-classifier.js +2 -3
- package/dist/agent/run-agent-context.d.ts +17 -0
- package/dist/agent/run-agent-context.js +80 -0
- package/dist/agent/run-agent-cron.js +12 -1
- package/dist/agent/run-agent-heartbeat.js +15 -2
- package/dist/agent/run-agent-team-task.js +15 -1
- package/dist/agent/run-agent.d.ts +6 -0
- package/dist/agent/run-agent.js +41 -9
- package/dist/gateway/failure-diagnostics.js +6 -1
- package/dist/gateway/heartbeat-scheduler.js +12 -2
- package/dist/gateway/outcome-grader.js +2 -3
- package/dist/gateway/router.d.ts +4 -3
- package/dist/gateway/router.js +47 -20
- package/dist/tools/admin-tools.js +10 -43
- package/dist/types.d.ts +24 -0
- package/package.json +1 -1
- package/dist/events/bus.d.ts +0 -43
- package/dist/events/bus.js +0 -136
|
@@ -62,18 +62,41 @@ const CRON_FIXER_PROMPT = [
|
|
|
62
62
|
'',
|
|
63
63
|
'Return: a one-paragraph summary of what you applied (or what is blocking apply), per job.',
|
|
64
64
|
].join('\n');
|
|
65
|
+
/** Build a routing-signal description for a hired agent.
|
|
66
|
+
* The SDK uses descriptions for auto-routing — they must be imperative
|
|
67
|
+
* ("Use for: ..."), not narrative prose. Otherwise the main agent
|
|
68
|
+
* has nothing to match user phrasings against. */
|
|
69
|
+
function buildHiredAgentDescription(p) {
|
|
70
|
+
const role = p.role ?? p.description ?? `${p.name}, a hired agent`;
|
|
71
|
+
const slug = p.slug;
|
|
72
|
+
const capabilities = (p.routingHints && p.routingHints.length > 0)
|
|
73
|
+
? p.routingHints.join(', ')
|
|
74
|
+
: (p.description ?? '').slice(0, 200);
|
|
75
|
+
return [
|
|
76
|
+
`Delegate to ${p.name} (${slug}).`,
|
|
77
|
+
capabilities ? `Use for: ${capabilities}.` : '',
|
|
78
|
+
`Role: ${role}.`,
|
|
79
|
+
'Spawn this subagent when the user names them, asks a question in their domain, or asks Clementine to "have <name> do X".',
|
|
80
|
+
].filter(Boolean).join(' ');
|
|
81
|
+
}
|
|
65
82
|
/** Map a hired-agent profile to an AgentDefinition.
|
|
66
83
|
* Used when Clementine wants to delegate to Ross/Sasha/Nora etc. */
|
|
67
84
|
function profileToAgentDefinition(p) {
|
|
85
|
+
// Always include `Agent` so the subagent can further fan out, plus
|
|
86
|
+
// core read tools as a baseline. profile.team.allowedTools narrows
|
|
87
|
+
// beyond this when set.
|
|
88
|
+
const baseline = ['Agent', 'Read', 'Grep', 'Glob', 'WebSearch', 'WebFetch', 'TodoWrite'];
|
|
89
|
+
const tools = p.team?.allowedTools?.length
|
|
90
|
+
? Array.from(new Set(['Agent', ...p.team.allowedTools]))
|
|
91
|
+
: baseline;
|
|
68
92
|
return {
|
|
69
|
-
description:
|
|
93
|
+
description: buildHiredAgentDescription(p),
|
|
70
94
|
prompt: p.systemPromptBody ?? `You are ${p.name}.`,
|
|
71
|
-
|
|
72
|
-
...(p.team?.allowedTools?.length ? { tools: p.team.allowedTools } : {}),
|
|
95
|
+
tools,
|
|
73
96
|
// Hired agents keep their configured model (Sonnet by default).
|
|
74
97
|
...(p.model ? { model: p.model } : { model: 'sonnet' }),
|
|
75
|
-
// Effort: hired agents do real work, default medium.
|
|
76
|
-
effort: 'medium',
|
|
98
|
+
// Effort: hired agents do real work, default medium. Profile may override.
|
|
99
|
+
...(p.effort ? { effort: p.effort } : { effort: 'medium' }),
|
|
77
100
|
};
|
|
78
101
|
}
|
|
79
102
|
/**
|
|
@@ -89,8 +112,19 @@ export function buildAgentMap(opts = {}) {
|
|
|
89
112
|
// ── System subagents ────────────────────────────────────────────
|
|
90
113
|
// Planner: opus, no tools, single turn. Used when the parent agent
|
|
91
114
|
// sees a multi-step request and wants a decomposition.
|
|
115
|
+
// Description is imperative + matches real user phrasings — the SDK
|
|
116
|
+
// matches against it for auto-routing, so prose doesn't trigger.
|
|
92
117
|
map['planner'] = {
|
|
93
|
-
description:
|
|
118
|
+
description: [
|
|
119
|
+
'Use this subagent BEFORE doing the work whenever the user request',
|
|
120
|
+
'involves 3 or more items, multiple distinct subtasks, or a phrase',
|
|
121
|
+
'like "research my top N", "for each X do Y", "look at all of",',
|
|
122
|
+
'"go through every", "do A, B, and C", or any task that would burn',
|
|
123
|
+
'context if processed serially. The planner returns a JSON plan',
|
|
124
|
+
'with parallel-safe steps; you then spawn researcher/cron-fixer/',
|
|
125
|
+
'hired-agent subagents per step. Always prefer this over doing',
|
|
126
|
+
'multi-item work yourself in the main conversation.',
|
|
127
|
+
].join(' '),
|
|
94
128
|
prompt: PLANNER_PROMPT,
|
|
95
129
|
model: 'opus',
|
|
96
130
|
tools: [], // pure reasoning, no tools
|
|
@@ -98,11 +132,18 @@ export function buildAgentMap(opts = {}) {
|
|
|
98
132
|
maxTurns: 1,
|
|
99
133
|
};
|
|
100
134
|
// Researcher: haiku, per-item investigation. Cheap fan-out target.
|
|
135
|
+
// No Bash — researcher is read-only fanout, must not mutate state.
|
|
101
136
|
map['researcher'] = {
|
|
102
|
-
description:
|
|
137
|
+
description: [
|
|
138
|
+
'Use this subagent to investigate ONE specific item — a single',
|
|
139
|
+
'lead, account, file, web page, or topic — and return a',
|
|
140
|
+
'one-paragraph summary. Spawn it in PARALLEL via the Agent tool',
|
|
141
|
+
'with one subagent per item when the planner returns multiple',
|
|
142
|
+
'research steps. Read-only: never mutates state. Cheap (Haiku).',
|
|
143
|
+
].join(' '),
|
|
103
144
|
prompt: RESEARCHER_PROMPT,
|
|
104
145
|
model: 'haiku',
|
|
105
|
-
tools: ['Read', 'Grep', 'Glob', '
|
|
146
|
+
tools: ['Read', 'Grep', 'Glob', 'WebSearch', 'WebFetch'],
|
|
106
147
|
effort: 'low',
|
|
107
148
|
maxTurns: 15,
|
|
108
149
|
};
|
|
@@ -27,13 +27,10 @@ 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
30
|
/** Format a millisecond duration as a human-friendly "X ago" string. */
|
|
32
31
|
export declare function formatTimeAgo(ms: number): string;
|
|
33
|
-
export declare function scrubInternalContextBlocks(text: string): string;
|
|
34
32
|
export declare function looksLikeOneMillionContextError(value: unknown): boolean;
|
|
35
33
|
export declare function oneMillionContextRecoveryMessage(): string;
|
|
36
|
-
export declare function looksLikeProviderApiErrorResponse(value: unknown): boolean;
|
|
37
34
|
export declare function looksLikeNoResponseRequested(value: unknown): boolean;
|
|
38
35
|
/** Autonomous jobs use this sentinel to mean "completed, but do not notify the owner." */
|
|
39
36
|
export declare function isAutonomousNothingOutput(response: string): boolean;
|
|
@@ -58,13 +55,6 @@ export interface ProactiveGoalInput {
|
|
|
58
55
|
nextActions?: string[];
|
|
59
56
|
};
|
|
60
57
|
}
|
|
61
|
-
/**
|
|
62
|
-
* Build the compact "active goals" block that gets injected when no goal
|
|
63
|
-
* keyword matches the user's prompt. Pure so it can be tested without the
|
|
64
|
-
* full Assistant/vault setup.
|
|
65
|
-
*/
|
|
66
|
-
export declare function buildActiveGoalsBlock(goals: ProactiveGoalInput[], agentSlug?: string | null, maxEntries?: number): string;
|
|
67
|
-
export declare function chunkReferencedInResponse(chunkContent: string, responseLower: string): boolean;
|
|
68
58
|
export declare class PersonalAssistant {
|
|
69
59
|
static readonly MAX_SESSION_EXCHANGES = 40;
|
|
70
60
|
private sessions;
|
package/dist/agent/assistant.js
CHANGED
|
@@ -169,10 +169,6 @@ export function estimateTokens(text) {
|
|
|
169
169
|
return 0;
|
|
170
170
|
return Math.ceil(text.length / 3.3);
|
|
171
171
|
}
|
|
172
|
-
export function looksLikeContextThrashText(value) {
|
|
173
|
-
const text = String(value ?? '');
|
|
174
|
-
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);
|
|
175
|
-
}
|
|
176
172
|
/**
|
|
177
173
|
* Strip lone Unicode surrogates (U+D800–U+DFFF) from a string so it can be
|
|
178
174
|
* safely serialized to JSON. Lone surrogates are valid in JS strings but
|
|
@@ -233,25 +229,12 @@ export function formatTimeAgo(ms) {
|
|
|
233
229
|
function capContextBlock(text, maxChars) {
|
|
234
230
|
return capOutput(String(text ?? ''), maxChars);
|
|
235
231
|
}
|
|
236
|
-
export function scrubInternalContextBlocks(text) {
|
|
237
|
-
return text
|
|
238
|
-
.replace(/\[Context governance:[^\]]*\][\s\S]*?\[\/Context governance:[^\]]*\]\s*/gi, '')
|
|
239
|
-
.replace(/\[Active working set\][\s\S]*?\[\/Active working set\]\s*/gi, '')
|
|
240
|
-
.replace(/\[Recent proactive notification context\][\s\S]*?\[\/Recent proactive notification context\]\s*/gi, '')
|
|
241
|
-
.trim();
|
|
242
|
-
}
|
|
243
232
|
export function looksLikeOneMillionContextError(value) {
|
|
244
233
|
return looksLikeClaudeOneMillionContextError(value);
|
|
245
234
|
}
|
|
246
235
|
export function oneMillionContextRecoveryMessage() {
|
|
247
236
|
return "Claude rejected 1M context for this account. I've switched Clementine to persistent 200K recovery mode and reset the session. Restart Clementine once so every background worker starts with the same safe setting.";
|
|
248
237
|
}
|
|
249
|
-
export function looksLikeProviderApiErrorResponse(value) {
|
|
250
|
-
const text = String(value ?? '').trim();
|
|
251
|
-
return /^api error:/i.test(text)
|
|
252
|
-
|| /^error:\s*api error:/i.test(text)
|
|
253
|
-
|| looksLikeOneMillionContextError(text);
|
|
254
|
-
}
|
|
255
238
|
export function looksLikeNoResponseRequested(value) {
|
|
256
239
|
const text = String(value ?? '').trim();
|
|
257
240
|
return /^no response requested\.?$/i.test(text);
|
|
@@ -711,72 +694,6 @@ export function removeProject(projectPath) {
|
|
|
711
694
|
_projectsMetaCacheTime = 0; // invalidate cache
|
|
712
695
|
return true;
|
|
713
696
|
}
|
|
714
|
-
// ── Retrieval Outcome Heuristic ─────────────────────────────────────
|
|
715
|
-
/**
|
|
716
|
-
* Decide whether a retrieved memory chunk shows up in the assistant's
|
|
717
|
-
* response. We key on distinctive tokens (multi-letter capitalized words,
|
|
718
|
-
* numbers of 2+ digits) that are unlikely to appear in the response unless
|
|
719
|
-
* the chunk's content actually influenced what was said.
|
|
720
|
-
*
|
|
721
|
-
* Intentionally a cheap local heuristic — no LLM call. False positives are
|
|
722
|
-
* tolerable since the outcome score is bounded and averaged over many
|
|
723
|
-
* observations.
|
|
724
|
-
*/
|
|
725
|
-
const OUTCOME_STOPWORDS = new Set([
|
|
726
|
-
'there', 'these', 'those', 'their', 'where', 'which', 'while',
|
|
727
|
-
'would', 'could', 'should', 'about', 'being', 'after', 'before',
|
|
728
|
-
'again', 'against', 'because',
|
|
729
|
-
]);
|
|
730
|
-
/**
|
|
731
|
-
* Build the compact "active goals" block that gets injected when no goal
|
|
732
|
-
* keyword matches the user's prompt. Pure so it can be tested without the
|
|
733
|
-
* full Assistant/vault setup.
|
|
734
|
-
*/
|
|
735
|
-
export function buildActiveGoalsBlock(goals, agentSlug, maxEntries = 6) {
|
|
736
|
-
if (goals.length === 0)
|
|
737
|
-
return '';
|
|
738
|
-
const filtered = goals.filter(({ goal }) => {
|
|
739
|
-
if (!agentSlug)
|
|
740
|
-
return true;
|
|
741
|
-
return goal.owner === agentSlug || goal.owner === 'clementine';
|
|
742
|
-
});
|
|
743
|
-
if (filtered.length === 0)
|
|
744
|
-
return '';
|
|
745
|
-
const rank = { high: 0, medium: 1, low: 2 };
|
|
746
|
-
const sorted = [...filtered].sort((a, b) => {
|
|
747
|
-
const ra = rank[a.goal.priority ?? 'medium'] ?? 1;
|
|
748
|
-
const rb = rank[b.goal.priority ?? 'medium'] ?? 1;
|
|
749
|
-
return ra - rb;
|
|
750
|
-
});
|
|
751
|
-
const top = sorted.slice(0, maxEntries);
|
|
752
|
-
const lines = top.map(({ goal }) => {
|
|
753
|
-
const next = goal.nextActions?.[0];
|
|
754
|
-
const nextBit = next ? ` → ${String(next).slice(0, 80)}` : '';
|
|
755
|
-
return `- [${goal.priority ?? 'medium'}] ${goal.title}${nextBit}`;
|
|
756
|
-
});
|
|
757
|
-
return `\n\n## Active Goals (background context)\n${lines.join('\n')}\n`;
|
|
758
|
-
}
|
|
759
|
-
export function chunkReferencedInResponse(chunkContent, responseLower) {
|
|
760
|
-
if (!chunkContent || !responseLower)
|
|
761
|
-
return false;
|
|
762
|
-
const distinctive = new Set();
|
|
763
|
-
const capMatches = chunkContent.match(/\b[A-Z][a-zA-Z]{3,}\b/g) ?? [];
|
|
764
|
-
for (const m of capMatches) {
|
|
765
|
-
const lower = m.toLowerCase();
|
|
766
|
-
if (!OUTCOME_STOPWORDS.has(lower))
|
|
767
|
-
distinctive.add(lower);
|
|
768
|
-
}
|
|
769
|
-
const numMatches = chunkContent.match(/\b\d{2,}\b/g) ?? [];
|
|
770
|
-
for (const m of numMatches)
|
|
771
|
-
distinctive.add(m);
|
|
772
|
-
if (distinctive.size === 0)
|
|
773
|
-
return false;
|
|
774
|
-
for (const tok of distinctive) {
|
|
775
|
-
if (responseLower.includes(tok))
|
|
776
|
-
return true;
|
|
777
|
-
}
|
|
778
|
-
return false;
|
|
779
|
-
}
|
|
780
697
|
// ── PersonalAssistant ───────────────────────────────────────────────
|
|
781
698
|
export class PersonalAssistant {
|
|
782
699
|
static MAX_SESSION_EXCHANGES = MAX_SESSION_EXCHANGES;
|
|
@@ -347,12 +347,11 @@ export async function classifyRoute(userMessage, agents, gateway) {
|
|
|
347
347
|
3, // maxTurns — classifier doesn't need tools
|
|
348
348
|
'haiku', // cheap
|
|
349
349
|
undefined, // workDir
|
|
350
|
-
'standard', // mode
|
|
350
|
+
'standard', // mode (display only)
|
|
351
351
|
undefined, // maxHours
|
|
352
352
|
undefined, // timeoutMs
|
|
353
353
|
undefined, // successCriteria
|
|
354
|
-
undefined
|
|
355
|
-
{ disableAllTools: true });
|
|
354
|
+
undefined);
|
|
356
355
|
}
|
|
357
356
|
catch (err) {
|
|
358
357
|
logger.warn({ err }, 'Route classifier call failed');
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AgentProfile } from '../types.js';
|
|
2
|
+
export interface BuildChatContextOptions {
|
|
3
|
+
/** Active hired-agent profile, when set. The agent-specific MEMORY.md
|
|
4
|
+
* in `agents/<slug>/MEMORY.md` is preferred over the global one. */
|
|
5
|
+
profile?: AgentProfile | null;
|
|
6
|
+
/** Optional caller-supplied systemPromptBody to append after the
|
|
7
|
+
* vault context block (used by hired-agent profiles). */
|
|
8
|
+
profileAppend?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Build the system-prompt append string for chat invocations.
|
|
12
|
+
*
|
|
13
|
+
* Returns an empty string when none of the source files exist — the
|
|
14
|
+
* SDK then runs with just the bare `claude_code` preset.
|
|
15
|
+
*/
|
|
16
|
+
export declare function buildChatSystemAppend(opts?: BuildChatContextOptions): string;
|
|
17
|
+
//# sourceMappingURL=run-agent-context.d.ts.map
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the vault context block that gets appended to the SDK's
|
|
3
|
+
* `claude_code` system prompt preset for chat sessions.
|
|
4
|
+
*
|
|
5
|
+
* The legacy chat path (`assistant.ts:buildSystemPrompt`) injected
|
|
6
|
+
* SOUL.md (personality), MEMORY.md (long-term memory), AGENTS.md
|
|
7
|
+
* (team awareness), and the agent-specific working-memory file. Without
|
|
8
|
+
* those, the canonical chat path loses the personality, preferences,
|
|
9
|
+
* and team-roster knowledge that distinguish Clementine from a generic
|
|
10
|
+
* SDK agent.
|
|
11
|
+
*
|
|
12
|
+
* Canonical pattern: SDK accepts `systemPrompt: { type: 'preset',
|
|
13
|
+
* preset: 'claude_code', append: <string> }`. We append a single
|
|
14
|
+
* concatenated context block — no wrappers, no recursive prompts.
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { SOUL_FILE, AGENTS_FILE, MEMORY_FILE, AGENTS_DIR } from '../config.js';
|
|
19
|
+
const SOUL_MAX_CHARS = 6_000;
|
|
20
|
+
const MEMORY_MAX_CHARS = 8_000;
|
|
21
|
+
const AGENTS_MAX_CHARS = 4_000;
|
|
22
|
+
const PROFILE_MEMORY_MAX_CHARS = 6_000;
|
|
23
|
+
function readFileSafe(p) {
|
|
24
|
+
try {
|
|
25
|
+
return fs.existsSync(p) ? fs.readFileSync(p, 'utf-8') : '';
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return '';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function trimTo(text, max) {
|
|
32
|
+
if (!text)
|
|
33
|
+
return '';
|
|
34
|
+
return text.length <= max ? text : text.slice(0, max - 3) + '...';
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Build the system-prompt append string for chat invocations.
|
|
38
|
+
*
|
|
39
|
+
* Returns an empty string when none of the source files exist — the
|
|
40
|
+
* SDK then runs with just the bare `claude_code` preset.
|
|
41
|
+
*/
|
|
42
|
+
export function buildChatSystemAppend(opts = {}) {
|
|
43
|
+
const blocks = [];
|
|
44
|
+
// 1. Soul (personality + voice)
|
|
45
|
+
const soul = readFileSafe(SOUL_FILE);
|
|
46
|
+
if (soul.trim()) {
|
|
47
|
+
blocks.push(`## Identity & Voice\n${trimTo(soul, SOUL_MAX_CHARS)}`);
|
|
48
|
+
}
|
|
49
|
+
// 2. Long-term memory — agent-specific file overrides the global one.
|
|
50
|
+
const profileMemoryPath = opts.profile?.slug
|
|
51
|
+
? path.join(AGENTS_DIR, opts.profile.slug, 'MEMORY.md')
|
|
52
|
+
: null;
|
|
53
|
+
let memory = '';
|
|
54
|
+
if (profileMemoryPath && fs.existsSync(profileMemoryPath)) {
|
|
55
|
+
memory = readFileSafe(profileMemoryPath);
|
|
56
|
+
if (memory.trim()) {
|
|
57
|
+
blocks.push(`## Long-Term Memory (${opts.profile?.name ?? opts.profile?.slug})\n${trimTo(memory, PROFILE_MEMORY_MAX_CHARS)}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
memory = readFileSafe(MEMORY_FILE);
|
|
62
|
+
if (memory.trim()) {
|
|
63
|
+
blocks.push(`## Long-Term Memory\n${trimTo(memory, MEMORY_MAX_CHARS)}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// 3. Team roster (only when not running AS a hired agent — Sasha
|
|
67
|
+
// doesn't need to be told who Sasha is).
|
|
68
|
+
if (!opts.profile) {
|
|
69
|
+
const agentsRoster = readFileSafe(AGENTS_FILE);
|
|
70
|
+
if (agentsRoster.trim()) {
|
|
71
|
+
blocks.push(`## Team Roster\n${trimTo(agentsRoster, AGENTS_MAX_CHARS)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// 4. Profile system prompt body (e.g. Sasha's role description).
|
|
75
|
+
if (opts.profileAppend?.trim()) {
|
|
76
|
+
blocks.push(opts.profileAppend);
|
|
77
|
+
}
|
|
78
|
+
return blocks.join('\n\n');
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=run-agent-context.js.map
|
|
@@ -301,12 +301,23 @@ export async function runAgentCron(opts) {
|
|
|
301
301
|
abortSignal: opts.abortSignal,
|
|
302
302
|
extraMcpServers: mcp.servers,
|
|
303
303
|
});
|
|
304
|
+
// Mirror the run into transcripts so future chat recall can see it.
|
|
305
|
+
// Legacy runCronJob did this with role='cron'; canonical needs the
|
|
306
|
+
// same so memory queries (`what did Sasha do this morning?`) work.
|
|
307
|
+
const deliverable = result.text ?? '';
|
|
308
|
+
if (opts.memoryStore && deliverable.trim()) {
|
|
309
|
+
try {
|
|
310
|
+
opts.memoryStore.saveTurn(`cron:${opts.jobName}`, 'cron', deliverable, opts.model ?? '');
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
logger.debug({ err, job: opts.jobName }, 'runAgentCron: transcript mirror failed (non-fatal)');
|
|
314
|
+
}
|
|
315
|
+
}
|
|
304
316
|
// ── Post-task hooks: reflection + skill extraction ────────────────
|
|
305
317
|
// Both fire-and-forget — never block the cron deliverable on these.
|
|
306
318
|
// They are the same passes the legacy runCronJob fires; without them
|
|
307
319
|
// the new path would lose the success-grading + procedural-memory
|
|
308
320
|
// growth that makes Clementine self-improving.
|
|
309
|
-
const deliverable = result.text ?? '';
|
|
310
321
|
if (opts.postTaskHooks && deliverable && deliverable.trim() !== '__NOTHING__') {
|
|
311
322
|
const durationMs = Date.now() - startedAt;
|
|
312
323
|
opts.postTaskHooks
|
|
@@ -65,8 +65,9 @@ export async function runAgentHeartbeat(opts) {
|
|
|
65
65
|
profile: opts.profile?.slug,
|
|
66
66
|
promptChars: prompt.length,
|
|
67
67
|
}, 'runAgentHeartbeat: dispatching to runAgent (no tools)');
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
const sessionKey = `heartbeat:${opts.profile?.slug ?? 'clementine'}`;
|
|
69
|
+
const result = await runAgent(prompt, {
|
|
70
|
+
sessionKey,
|
|
70
71
|
source: 'heartbeat',
|
|
71
72
|
profile: opts.profile,
|
|
72
73
|
memoryStore: opts.memoryStore,
|
|
@@ -80,5 +81,17 @@ export async function runAgentHeartbeat(opts) {
|
|
|
80
81
|
allowedTools: [],
|
|
81
82
|
abortSignal: opts.abortSignal,
|
|
82
83
|
});
|
|
84
|
+
// Mirror the heartbeat into transcripts so dedup + recall work.
|
|
85
|
+
// Skip pure __NOTHING__ outputs since they carry no information.
|
|
86
|
+
const text = result.text?.trim() ?? '';
|
|
87
|
+
if (opts.memoryStore && text && text !== '__NOTHING__') {
|
|
88
|
+
try {
|
|
89
|
+
opts.memoryStore.saveTurn(sessionKey, 'heartbeat', text, opts.model ?? MODELS.haiku);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
/* non-fatal */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
83
96
|
}
|
|
84
97
|
//# sourceMappingURL=run-agent-heartbeat.js.map
|
|
@@ -49,8 +49,9 @@ export async function runAgentTeamTask(opts) {
|
|
|
49
49
|
droppedComposio: mcp.droppedComposio,
|
|
50
50
|
promptChars: builtPrompt.length,
|
|
51
51
|
}, 'runAgentTeamTask: dispatching to runAgent');
|
|
52
|
+
const sessionKey = `team-task:${opts.fromSlug}->${opts.profile.slug}`;
|
|
52
53
|
const result = await runAgent(builtPrompt, {
|
|
53
|
-
sessionKey
|
|
54
|
+
sessionKey,
|
|
54
55
|
source: 'team-task',
|
|
55
56
|
profile: opts.profile,
|
|
56
57
|
agentManager: opts.agentManager,
|
|
@@ -62,6 +63,19 @@ export async function runAgentTeamTask(opts) {
|
|
|
62
63
|
abortSignal: opts.abortSignal,
|
|
63
64
|
extraMcpServers: mcp.servers,
|
|
64
65
|
});
|
|
66
|
+
// Mirror the inbound message + outbound response into transcripts so
|
|
67
|
+
// future recall sees who-asked-whom and what got done.
|
|
68
|
+
if (opts.memoryStore) {
|
|
69
|
+
try {
|
|
70
|
+
opts.memoryStore.saveTurn(sessionKey, `team-from:${opts.fromSlug}`, opts.content, '');
|
|
71
|
+
if (result.text?.trim()) {
|
|
72
|
+
opts.memoryStore.saveTurn(sessionKey, `team-to:${opts.profile.slug}`, result.text, opts.model ?? '');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
/* non-fatal */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
65
79
|
return {
|
|
66
80
|
...result,
|
|
67
81
|
builtPrompt,
|
|
@@ -77,6 +77,12 @@ export interface RunAgentOptions {
|
|
|
77
77
|
url?: string;
|
|
78
78
|
headers?: Record<string, string>;
|
|
79
79
|
}>;
|
|
80
|
+
/** String appended to the SDK's `claude_code` system-prompt preset.
|
|
81
|
+
* Caller-built so chat callers can inject vault context (SOUL.md,
|
|
82
|
+
* MEMORY.md, AGENTS.md) while autonomous callers (cron/heartbeat/
|
|
83
|
+
* team-task) keep the prompt small. When unset, falls back to
|
|
84
|
+
* profile.systemPromptBody (legacy single-source behavior). */
|
|
85
|
+
systemPromptAppend?: string;
|
|
80
86
|
}
|
|
81
87
|
export interface RunAgentResult {
|
|
82
88
|
/** Final text response from the agent. */
|
package/dist/agent/run-agent.js
CHANGED
|
@@ -111,14 +111,28 @@ export async function runAgent(prompt, opts) {
|
|
|
111
111
|
const effectivePrompt = opts.forceSubagent && agents[opts.forceSubagent]
|
|
112
112
|
? `Use the ${opts.forceSubagent} agent to handle this request:\n\n${prompt}`
|
|
113
113
|
: prompt;
|
|
114
|
-
// Compose system
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
114
|
+
// Compose system-prompt append. The caller has already merged any
|
|
115
|
+
// vault context (SOUL.md, MEMORY.md, AGENTS.md) and profile body
|
|
116
|
+
// into a single string when needed; otherwise we fall back to the
|
|
117
|
+
// profile body alone for autonomous paths.
|
|
118
|
+
const profileAppend = opts.systemPromptAppend?.trim()
|
|
119
|
+
? opts.systemPromptAppend
|
|
120
|
+
: opts.profile?.systemPromptBody?.trim()
|
|
121
|
+
? opts.profile.systemPromptBody
|
|
122
|
+
: undefined;
|
|
123
|
+
// Allowed tools at the main-agent level.
|
|
124
|
+
// 1. Caller-provided opts.allowedTools wins (e.g. heartbeat passes []).
|
|
125
|
+
// 2. When a hired-agent profile is the main agent and it has a
|
|
126
|
+
// team.allowedTools allowlist, use it (with `Agent` always
|
|
127
|
+
// included so subagent delegation still works).
|
|
128
|
+
// 3. Otherwise the core set. Per-subagent tool restrictions live
|
|
129
|
+
// on each AgentDefinition.tools field, not here.
|
|
130
|
+
const profileMainAllow = opts.profile?.team?.allowedTools?.length
|
|
131
|
+
? Array.from(new Set(['Agent', ...opts.profile.team.allowedTools]))
|
|
132
|
+
: null;
|
|
133
|
+
const allowedTools = opts.allowedTools
|
|
134
|
+
?? profileMainAllow
|
|
135
|
+
?? CORE_TOOLS_FOR_AGENT_PARENT;
|
|
122
136
|
// Wire the Clementine MCP server so the agent can reach memory/cron/
|
|
123
137
|
// broken-job tools. Without this, the cron-fixer subagent's `tools`
|
|
124
138
|
// list references mcp__clementine-tools__* that don't exist in the
|
|
@@ -141,6 +155,20 @@ export async function runAgent(prompt, opts) {
|
|
|
141
155
|
},
|
|
142
156
|
...(opts.extraMcpServers ?? {}),
|
|
143
157
|
};
|
|
158
|
+
// Bridge an external AbortSignal to a real AbortController the SDK
|
|
159
|
+
// can act on. The SDK calls .abort() internally on budget/turn caps,
|
|
160
|
+
// so we cannot pass a fake { signal } object — it must be a real
|
|
161
|
+
// controller. When the caller's signal fires we propagate.
|
|
162
|
+
let sdkAbortController;
|
|
163
|
+
if (opts.abortSignal) {
|
|
164
|
+
sdkAbortController = new AbortController();
|
|
165
|
+
if (opts.abortSignal.aborted) {
|
|
166
|
+
sdkAbortController.abort();
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
opts.abortSignal.addEventListener('abort', () => sdkAbortController.abort(), { once: true });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
144
172
|
// Apply 1M-context env normalization (existing infra)
|
|
145
173
|
const sdkOptionsRaw = {
|
|
146
174
|
systemPrompt: profileAppend
|
|
@@ -153,6 +181,10 @@ export async function runAgent(prompt, opts) {
|
|
|
153
181
|
mcpServers: mcpServers,
|
|
154
182
|
allowedTools,
|
|
155
183
|
permissionMode: 'bypassPermissions',
|
|
184
|
+
// SDK spec requires this companion flag whenever permissionMode is
|
|
185
|
+
// 'bypassPermissions'. Without it, autonomous runs can silently
|
|
186
|
+
// hang waiting for permission prompts.
|
|
187
|
+
allowDangerouslySkipPermissions: true,
|
|
156
188
|
cwd: BASE_DIR,
|
|
157
189
|
env: subprocessEnv,
|
|
158
190
|
maxBudgetUsd,
|
|
@@ -160,7 +192,7 @@ export async function runAgent(prompt, opts) {
|
|
|
160
192
|
...(opts.maxTurns ? { maxTurns: opts.maxTurns } : {}),
|
|
161
193
|
...(opts.model ? { model: opts.model } : {}),
|
|
162
194
|
...(opts.resumeSessionId ? { resume: opts.resumeSessionId } : {}),
|
|
163
|
-
...(
|
|
195
|
+
...(sdkAbortController ? { abortController: sdkAbortController } : {}),
|
|
164
196
|
};
|
|
165
197
|
const sdkOptions = normalizeClaudeSdkOptionsForOneMillionContext(sdkOptionsRaw);
|
|
166
198
|
logger.info({
|
|
@@ -541,7 +541,12 @@ export async function diagnoseBrokenJob(broken, gateway) {
|
|
|
541
541
|
rawResponse = await gateway.handleCronJob(`diagnose:${broken.jobName}`, prompt, 1, // tier 1 — cheap
|
|
542
542
|
5, // maxTurns — diagnosis doesn't need tools typically
|
|
543
543
|
'haiku', // model — keep cost negligible
|
|
544
|
-
undefined,
|
|
544
|
+
undefined, // workDir
|
|
545
|
+
'standard', // mode (display only)
|
|
546
|
+
undefined, // maxHours
|
|
547
|
+
undefined, // timeoutMs
|
|
548
|
+
undefined, // successCriteria
|
|
549
|
+
undefined);
|
|
545
550
|
}
|
|
546
551
|
catch (err) {
|
|
547
552
|
logger.warn({ err, job: broken.jobName }, 'Diagnostic LLM call failed');
|
|
@@ -294,7 +294,12 @@ export class HeartbeatScheduler {
|
|
|
294
294
|
// LLM callback for summarization/principle extraction
|
|
295
295
|
const llmCall = async (prompt) => {
|
|
296
296
|
const cronCall = buildConsolidationCronCall(prompt);
|
|
297
|
-
const result = await this.gateway.handleCronJob(cronCall.jobName, cronCall.jobPrompt, cronCall.tier, cronCall.maxTurns, cronCall.model, undefined,
|
|
297
|
+
const result = await this.gateway.handleCronJob(cronCall.jobName, cronCall.jobPrompt, cronCall.tier, cronCall.maxTurns, cronCall.model, undefined, // workDir
|
|
298
|
+
'standard', // mode (display only)
|
|
299
|
+
undefined, // maxHours
|
|
300
|
+
undefined, // timeoutMs
|
|
301
|
+
undefined, // successCriteria
|
|
302
|
+
undefined);
|
|
298
303
|
return result || '';
|
|
299
304
|
};
|
|
300
305
|
const result = await runConsolidation(store, llmCall);
|
|
@@ -978,7 +983,12 @@ export class HeartbeatScheduler {
|
|
|
978
983
|
let response = null;
|
|
979
984
|
try {
|
|
980
985
|
const cronCall = buildInsightCheckCronCall(prompt);
|
|
981
|
-
response = await this.gateway.handleCronJob(cronCall.jobName, cronCall.jobPrompt, cronCall.tier, cronCall.maxTurns, cronCall.model, undefined,
|
|
986
|
+
response = await this.gateway.handleCronJob(cronCall.jobName, cronCall.jobPrompt, cronCall.tier, cronCall.maxTurns, cronCall.model, undefined, // workDir
|
|
987
|
+
'standard', // mode (display only)
|
|
988
|
+
undefined, // maxHours
|
|
989
|
+
undefined, // timeoutMs
|
|
990
|
+
undefined, // successCriteria
|
|
991
|
+
undefined);
|
|
982
992
|
this.runLog.append({
|
|
983
993
|
jobName: 'insight-check',
|
|
984
994
|
startedAt: icStartedAt.toISOString(),
|
|
@@ -136,12 +136,11 @@ export async function gradeRun(entry, gateway, jobPrompt) {
|
|
|
136
136
|
raw = await gateway.handleCronJob(`grade:${entry.jobName}`, prompt, 1, // tier 1
|
|
137
137
|
3, // maxTurns — tight
|
|
138
138
|
'haiku', undefined, // workDir
|
|
139
|
-
'standard', // mode
|
|
139
|
+
'standard', // mode (display only)
|
|
140
140
|
undefined, // maxHours
|
|
141
141
|
undefined, // timeoutMs
|
|
142
142
|
undefined, // successCriteria
|
|
143
|
-
undefined
|
|
144
|
-
{ disableAllTools: true });
|
|
143
|
+
undefined);
|
|
145
144
|
}
|
|
146
145
|
catch (err) {
|
|
147
146
|
logger.warn({ err, jobName: entry.jobName }, 'Outcome grader LLM call failed');
|
package/dist/gateway/router.d.ts
CHANGED
|
@@ -167,9 +167,10 @@ export declare class Gateway {
|
|
|
167
167
|
handleMessage(sessionKey: string, text: string, onText?: OnTextCallback, model?: string, maxTurns?: number, onToolActivity?: OnToolActivityCallback, onProgress?: OnProgressCallback): Promise<string>;
|
|
168
168
|
private _handleMessageInner;
|
|
169
169
|
handleHeartbeat(standingInstructions: string, changesSummary?: string, timeContext?: string, dedupContext?: string, profile?: import('../types.js').AgentProfile | null): Promise<string>;
|
|
170
|
-
handleCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string,
|
|
171
|
-
|
|
172
|
-
|
|
170
|
+
handleCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string,
|
|
171
|
+
/** Accepted for back-compat; canonical SDK path executes every job
|
|
172
|
+
* identically. Affects only UI display + budget heuristics elsewhere. */
|
|
173
|
+
_mode?: 'standard' | 'unleashed', maxHours?: number, timeoutMs?: number, successCriteria?: string[], agentSlug?: string): Promise<string>;
|
|
173
174
|
/**
|
|
174
175
|
* Process a team message as an autonomous task — same multi-phase execution
|
|
175
176
|
* as cron unleashed jobs, so agents can work until done instead of being
|
package/dist/gateway/router.js
CHANGED
|
@@ -16,7 +16,6 @@ import { lanes } from './lanes.js';
|
|
|
16
16
|
import { AgentManager } from '../agent/agent-manager.js';
|
|
17
17
|
import { TeamRouter } from '../agent/team-router.js';
|
|
18
18
|
import { TeamBus } from '../agent/team-bus.js';
|
|
19
|
-
import { events } from '../events/bus.js';
|
|
20
19
|
import { listBackgroundTasks, loadBackgroundTask, markFailed } from '../agent/background-tasks.js';
|
|
21
20
|
import { applyAssistantExperienceUpdate, detectApprovalReply, detectLocalTurn } from '../agent/local-turn.js';
|
|
22
21
|
import { buildApprovalFollowupPrompt, detectActionExpectation } from '../agent/action-enforcer.js';
|
|
@@ -1497,7 +1496,6 @@ export class Gateway {
|
|
|
1497
1496
|
logger.info({ sessionKey, laneWaitMs }, 'Chat lane wait was non-trivial');
|
|
1498
1497
|
}
|
|
1499
1498
|
logger.info(`Message from ${sessionKey}: ${text.slice(0, 100)}...`);
|
|
1500
|
-
events.emit('message:received', { sessionKey, text, timestamp: Date.now() });
|
|
1501
1499
|
// ── Register provenance on first interaction ────────────────
|
|
1502
1500
|
this.ensureProvenance(sessionKey);
|
|
1503
1501
|
// ── Pre-flight injection scan ───────────────────────────────
|
|
@@ -1780,10 +1778,32 @@ export class Gateway {
|
|
|
1780
1778
|
// runAgent() owns chat. No legacy fallback — errors propagate
|
|
1781
1779
|
// to the catch block below for honest classification.
|
|
1782
1780
|
const { runAgent } = await import('../agent/run-agent.js');
|
|
1781
|
+
const { buildExtraMcpForRunAgent } = await import('../agent/run-agent-mcp.js');
|
|
1782
|
+
const { buildChatSystemAppend } = await import('../agent/run-agent-context.js');
|
|
1783
|
+
// Wire Composio + external MCP servers (Outlook, Gmail,
|
|
1784
|
+
// Salesforce, etc) so chat can reach the same tools the
|
|
1785
|
+
// legacy chat path did. Profile allowlists override the
|
|
1786
|
+
// bundle router when set.
|
|
1787
|
+
const chatMcp = await buildExtraMcpForRunAgent({
|
|
1788
|
+
scopeText: chatPrompt,
|
|
1789
|
+
profile: resolvedProfile,
|
|
1790
|
+
});
|
|
1791
|
+
// Inject vault context (SOUL.md / MEMORY.md / AGENTS.md +
|
|
1792
|
+
// optional profile body) into the system-prompt append so
|
|
1793
|
+
// the agent has personality + long-term memory + team
|
|
1794
|
+
// awareness. Profile-specific MEMORY.md takes precedence
|
|
1795
|
+
// over the global one when a hired agent is active.
|
|
1796
|
+
const chatSystemAppend = buildChatSystemAppend({
|
|
1797
|
+
profile: resolvedProfile,
|
|
1798
|
+
profileAppend: resolvedProfile?.systemPromptBody,
|
|
1799
|
+
});
|
|
1783
1800
|
logger.info({
|
|
1784
1801
|
sessionKey: effectiveSessionKey,
|
|
1785
1802
|
profile: resolvedProfile?.slug,
|
|
1786
1803
|
path: 'runagent_chat',
|
|
1804
|
+
composioConnected: chatMcp.composioConnected.length,
|
|
1805
|
+
externalConnected: chatMcp.externalConnected.length,
|
|
1806
|
+
systemAppendChars: chatSystemAppend.length,
|
|
1787
1807
|
}, 'Routing chat through runAgent');
|
|
1788
1808
|
const runAgentResult = await runAgent(chatPrompt, {
|
|
1789
1809
|
sessionKey: effectiveSessionKey,
|
|
@@ -1793,6 +1813,8 @@ export class Gateway {
|
|
|
1793
1813
|
memoryStore: this.assistant.getMemoryStore?.() ?? null,
|
|
1794
1814
|
...(effectiveModel ? { model: effectiveModel } : {}),
|
|
1795
1815
|
...(maxTurns ? { maxTurns } : {}),
|
|
1816
|
+
...(chatSystemAppend ? { systemPromptAppend: chatSystemAppend } : {}),
|
|
1817
|
+
extraMcpServers: chatMcp.servers,
|
|
1796
1818
|
onText: wrappedOnText,
|
|
1797
1819
|
onToolActivity: ({ tool, input }) => {
|
|
1798
1820
|
toolActivityCount++;
|
|
@@ -1888,7 +1910,6 @@ export class Gateway {
|
|
|
1888
1910
|
return '__NOTHING__';
|
|
1889
1911
|
}
|
|
1890
1912
|
logger.info({ agent }, 'Running heartbeat...');
|
|
1891
|
-
events.emit('heartbeat:start', { agent, timestamp: Date.now() });
|
|
1892
1913
|
const hbStart = Date.now();
|
|
1893
1914
|
try {
|
|
1894
1915
|
const { runAgentHeartbeat } = await import('../agent/run-agent-heartbeat.js');
|
|
@@ -1902,21 +1923,16 @@ export class Gateway {
|
|
|
1902
1923
|
memoryStore: this.assistant.getMemoryStore?.() ?? null,
|
|
1903
1924
|
});
|
|
1904
1925
|
scanner.refreshIntegrity();
|
|
1905
|
-
events.emit('heartbeat:complete', {
|
|
1906
|
-
agent,
|
|
1907
|
-
durationMs: Date.now() - hbStart,
|
|
1908
|
-
responseLength: result.text?.length ?? 0,
|
|
1909
|
-
});
|
|
1910
1926
|
logger.info({
|
|
1911
1927
|
agent,
|
|
1912
1928
|
cost: Number(result.totalCostUsd.toFixed(4)),
|
|
1913
1929
|
numTurns: result.numTurns,
|
|
1914
1930
|
durationMs: Date.now() - hbStart,
|
|
1931
|
+
responseLen: result.text?.length ?? 0,
|
|
1915
1932
|
}, 'runAgentHeartbeat: heartbeat complete');
|
|
1916
1933
|
return result.text;
|
|
1917
1934
|
}
|
|
1918
1935
|
catch (err) {
|
|
1919
|
-
events.emit('heartbeat:error', { agent, error: String(err) });
|
|
1920
1936
|
logger.error({ err }, 'Heartbeat error');
|
|
1921
1937
|
return `Heartbeat error: ${err}`;
|
|
1922
1938
|
}
|
|
@@ -1925,18 +1941,34 @@ export class Gateway {
|
|
|
1925
1941
|
releaseLane();
|
|
1926
1942
|
}
|
|
1927
1943
|
}
|
|
1928
|
-
async handleCronJob(jobName, jobPrompt, tier = 1, maxTurns, model, workDir,
|
|
1944
|
+
async handleCronJob(jobName, jobPrompt, tier = 1, maxTurns, model, workDir,
|
|
1945
|
+
/** Accepted for back-compat; canonical SDK path executes every job
|
|
1946
|
+
* identically. Affects only UI display + budget heuristics elsewhere. */
|
|
1947
|
+
_mode, maxHours, timeoutMs, successCriteria, agentSlug) {
|
|
1929
1948
|
const releaseLane = await lanes.acquire('cron');
|
|
1949
|
+
// Build a wall-clock abort timer from maxHours / timeoutMs.
|
|
1950
|
+
// Whichever is shorter wins. Defaults to 1h if neither is set.
|
|
1951
|
+
const wallMs = (() => {
|
|
1952
|
+
const fromHours = maxHours && maxHours > 0 ? maxHours * 3600 * 1000 : null;
|
|
1953
|
+
const fromMs = timeoutMs && timeoutMs > 0 ? timeoutMs : null;
|
|
1954
|
+
if (fromHours && fromMs)
|
|
1955
|
+
return Math.min(fromHours, fromMs);
|
|
1956
|
+
return fromHours ?? fromMs ?? 60 * 60 * 1000;
|
|
1957
|
+
})();
|
|
1958
|
+
const cronAc = new AbortController();
|
|
1959
|
+
const cronTimer = setTimeout(() => {
|
|
1960
|
+
cronAc.abort();
|
|
1961
|
+
logger.warn({ jobName, wallMs }, 'Cron job hit wall-clock cap — aborting');
|
|
1962
|
+
}, wallMs);
|
|
1930
1963
|
try {
|
|
1931
1964
|
logger.info(`Running cron job: ${jobName}${workDir ? ` in ${workDir}` : ''}${agentSlug && agentSlug !== 'clementine' ? ` as ${agentSlug}` : ''}`);
|
|
1932
|
-
events.emit('cron:start', { jobName, tier, mode: 'runagent', timestamp: Date.now() });
|
|
1933
1965
|
const cronStart = Date.now();
|
|
1934
1966
|
try {
|
|
1935
1967
|
const { runAgentCron } = await import('../agent/run-agent-cron.js');
|
|
1936
1968
|
const profile = agentSlug && agentSlug !== 'clementine'
|
|
1937
1969
|
? this.getAgentManager().get(agentSlug) ?? null
|
|
1938
1970
|
: null;
|
|
1939
|
-
logger.info({ jobName, agentSlug, tier, path: 'runagent_cron' }, 'Routing cron through runAgentCron');
|
|
1971
|
+
logger.info({ jobName, agentSlug, tier, wallMs, path: 'runagent_cron' }, 'Routing cron through runAgentCron');
|
|
1940
1972
|
const cronResult = await runAgentCron({
|
|
1941
1973
|
jobName,
|
|
1942
1974
|
jobPrompt,
|
|
@@ -1948,15 +1980,10 @@ export class Gateway {
|
|
|
1948
1980
|
successCriteria,
|
|
1949
1981
|
model,
|
|
1950
1982
|
workDir,
|
|
1983
|
+
abortSignal: cronAc.signal,
|
|
1951
1984
|
postTaskHooks: this.assistant,
|
|
1952
1985
|
});
|
|
1953
1986
|
scanner.refreshIntegrity();
|
|
1954
|
-
events.emit('cron:complete', {
|
|
1955
|
-
jobName,
|
|
1956
|
-
mode: 'runagent',
|
|
1957
|
-
durationMs: Date.now() - cronStart,
|
|
1958
|
-
responseLength: cronResult.text?.length ?? 0,
|
|
1959
|
-
});
|
|
1960
1987
|
logger.info({
|
|
1961
1988
|
jobName,
|
|
1962
1989
|
cost: Number(cronResult.totalCostUsd.toFixed(4)),
|
|
@@ -1968,16 +1995,16 @@ export class Gateway {
|
|
|
1968
1995
|
return cronResult.text;
|
|
1969
1996
|
}
|
|
1970
1997
|
catch (err) {
|
|
1971
|
-
events.emit('cron:error', { jobName, mode: 'runagent', error: String(err) });
|
|
1972
1998
|
logger.error({ err, jobName }, `Cron job error: ${jobName}`);
|
|
1973
1999
|
throw err;
|
|
1974
2000
|
}
|
|
1975
2001
|
}
|
|
1976
2002
|
finally {
|
|
2003
|
+
clearTimeout(cronTimer);
|
|
1977
2004
|
releaseLane();
|
|
1978
2005
|
}
|
|
1979
2006
|
}
|
|
1980
|
-
// ── Team task execution
|
|
2007
|
+
// ── Team task execution ──────────────────────────────────────────────
|
|
1981
2008
|
/**
|
|
1982
2009
|
* Process a team message as an autonomous task — same multi-phase execution
|
|
1983
2010
|
* as cron unleashed jobs, so agents can work until done instead of being
|
|
@@ -992,7 +992,6 @@ export function registerAdminTools(server) {
|
|
|
992
992
|
const schedule = String(job.schedule ?? '');
|
|
993
993
|
const prompt = String(job.prompt ?? '');
|
|
994
994
|
const enabled = job.enabled !== false;
|
|
995
|
-
const mode = job.mode === 'unleashed' ? 'unleashed' : 'standard';
|
|
996
995
|
const workDir = job.work_dir ? String(job.work_dir) : null;
|
|
997
996
|
const humanSchedule = describeCronSchedule(schedule);
|
|
998
997
|
const nextRun = enabled ? getNextRun(schedule) : null;
|
|
@@ -1016,7 +1015,7 @@ export function registerAdminTools(server) {
|
|
|
1016
1015
|
}
|
|
1017
1016
|
}
|
|
1018
1017
|
const status = enabled ? 'enabled' : 'disabled';
|
|
1019
|
-
lines.push(`**${name}** [${status}]
|
|
1018
|
+
lines.push(`**${name}** [${status}] ` +
|
|
1020
1019
|
`\n Schedule: ${humanSchedule} (\`${schedule}\`)` +
|
|
1021
1020
|
(nextRun ? `\n Next run: ${nextRun}` : '') +
|
|
1022
1021
|
(lastRunInfo ? `\n ${lastRunInfo}` : '') +
|
|
@@ -1026,47 +1025,20 @@ export function registerAdminTools(server) {
|
|
|
1026
1025
|
return textResult(lines.join('\n\n'));
|
|
1027
1026
|
});
|
|
1028
1027
|
// ── Add Cron Job ────────────────────────────────────────────────────────
|
|
1029
|
-
server.tool('add_cron_job', 'Add a new scheduled cron job. Validates the schedule expression and writes to CRON.md. The daemon auto-reloads on file change.
|
|
1028
|
+
server.tool('add_cron_job', 'Add a new scheduled cron job. Validates the schedule expression and writes to CRON.md. The daemon auto-reloads on file change. The canonical SDK path runs every job through runAgentCron — there is no separate "unleashed" mode anymore; the SDK handles compaction + multi-turn work natively up to maxBudgetUsd.', {
|
|
1030
1029
|
name: z.string().describe('Job name (unique identifier)'),
|
|
1031
1030
|
schedule: z.string().describe('Cron expression (e.g., "0 9 * * 1" for Monday 9 AM)'),
|
|
1032
1031
|
prompt: z.string().describe('The prompt/instruction for the assistant to execute'),
|
|
1033
|
-
tier: z.number().optional().default(1).describe('Security tier (1=auto, 2=logged, 3=approval)'),
|
|
1032
|
+
tier: z.number().optional().default(1).describe('Security tier (1=auto, 2=logged, 3=approval). Tier 2+ also raises the per-run budget cap.'),
|
|
1034
1033
|
enabled: z.boolean().optional().default(true).describe('Whether the job is enabled'),
|
|
1035
1034
|
work_dir: z.string().optional().describe('Project directory to run in (agent gets access to project tools, CLAUDE.md, files)'),
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
}, async ({ name: jobName, schedule, prompt, tier, enabled, work_dir, mode: rawMode, max_hours: rawMaxHours }) => {
|
|
1039
|
-
let mode = rawMode;
|
|
1040
|
-
let max_hours = rawMaxHours;
|
|
1035
|
+
max_hours: z.number().optional().describe('Wall-clock cap in hours. Defaults to 1h. Run aborts via AbortSignal when exceeded.'),
|
|
1036
|
+
}, async ({ name: jobName, schedule, prompt, tier, enabled, work_dir, max_hours }) => {
|
|
1041
1037
|
// Validate cron expression
|
|
1042
1038
|
const cronMod = await import('node-cron');
|
|
1043
1039
|
if (!cronMod.default.validate(schedule)) {
|
|
1044
1040
|
return textResult(`Invalid cron expression: "${schedule}". Examples: "0 9 * * 1" (Mon 9 AM), "*/30 * * * *" (every 30 min).`);
|
|
1045
1041
|
}
|
|
1046
|
-
// Auto-escalate to unleashed when the job clearly needs it.
|
|
1047
|
-
// Tier 2 jobs with complex prompts (browser automation, multi-contact workflows,
|
|
1048
|
-
// multi-step sequences) will exhaust standard turn limits silently.
|
|
1049
|
-
if (mode !== 'unleashed' && tier >= 2) {
|
|
1050
|
-
const complexSignals = [
|
|
1051
|
-
/\bfor each\b.*\bcontact\b/i,
|
|
1052
|
-
/\bfor each\b.*\bprospect\b/i,
|
|
1053
|
-
/\bfor each\b.*\baccount\b/i,
|
|
1054
|
-
/\bfor each\b.*\blead\b/i,
|
|
1055
|
-
/\bfor each\b.*\bprofile\b/i,
|
|
1056
|
-
/\bplaywright\b/i,
|
|
1057
|
-
/\bkernel\s+browsers?\b/i,
|
|
1058
|
-
/\bbrowser\b.*\bautomati/i,
|
|
1059
|
-
/\bstep\s+\d+\b.*\bstep\s+\d+\b/is,
|
|
1060
|
-
];
|
|
1061
|
-
const isComplex = complexSignals.some(p => p.test(prompt))
|
|
1062
|
-
|| prompt.length > 2000;
|
|
1063
|
-
if (isComplex) {
|
|
1064
|
-
mode = 'unleashed';
|
|
1065
|
-
if (!max_hours)
|
|
1066
|
-
max_hours = 1;
|
|
1067
|
-
logger.info({ jobName }, 'Auto-escalated to unleashed mode (complex prompt detected)');
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
1042
|
// Read existing CRON.md or create empty structure
|
|
1071
1043
|
const matterMod = await import('gray-matter');
|
|
1072
1044
|
let parsed;
|
|
@@ -1097,11 +1069,8 @@ export function registerAdminTools(server) {
|
|
|
1097
1069
|
};
|
|
1098
1070
|
if (work_dir)
|
|
1099
1071
|
newJob.work_dir = work_dir;
|
|
1100
|
-
if (
|
|
1101
|
-
newJob.
|
|
1102
|
-
if (max_hours)
|
|
1103
|
-
newJob.max_hours = max_hours;
|
|
1104
|
-
}
|
|
1072
|
+
if (max_hours)
|
|
1073
|
+
newJob.max_hours = max_hours;
|
|
1105
1074
|
jobs.push(newJob);
|
|
1106
1075
|
parsed.data.jobs = jobs;
|
|
1107
1076
|
// Write back preserving body content — validate first to prevent daemon crash
|
|
@@ -1113,7 +1082,7 @@ export function registerAdminTools(server) {
|
|
|
1113
1082
|
return textResult(`Failed to add job "${jobName}": generated YAML is invalid. Error: ${yamlErr}`);
|
|
1114
1083
|
}
|
|
1115
1084
|
writeFileSync(CRON_FILE, output);
|
|
1116
|
-
logger.info({ jobName, schedule, tier,
|
|
1085
|
+
logger.info({ jobName, schedule, tier, work_dir, max_hours }, 'Added cron job via MCP tool');
|
|
1117
1086
|
// Read-back verification: confirm the job was persisted correctly
|
|
1118
1087
|
let verified = false;
|
|
1119
1088
|
try {
|
|
@@ -1134,10 +1103,8 @@ export function registerAdminTools(server) {
|
|
|
1134
1103
|
];
|
|
1135
1104
|
if (work_dir)
|
|
1136
1105
|
details.push(` Project: ${work_dir}`);
|
|
1137
|
-
if (
|
|
1138
|
-
|
|
1139
|
-
details.push(` Mode: unleashed (max ${max_hours ?? 6} hours)${escalated}`);
|
|
1140
|
-
}
|
|
1106
|
+
if (max_hours)
|
|
1107
|
+
details.push(` Wall-clock cap: ${max_hours}h`);
|
|
1141
1108
|
const verifyMsg = verified
|
|
1142
1109
|
? 'Verified: job persisted to CRON.md and will be picked up by the daemon.'
|
|
1143
1110
|
: 'WARNING: Could not verify the job was written correctly. Check CRON.md manually.';
|
package/dist/types.d.ts
CHANGED
|
@@ -196,6 +196,25 @@ export interface AgentProfile {
|
|
|
196
196
|
start: number;
|
|
197
197
|
end: number;
|
|
198
198
|
};
|
|
199
|
+
/**
|
|
200
|
+
* Short imperative routing hints used to build this agent's
|
|
201
|
+
* AgentDefinition.description for SDK auto-routing. Each entry is a
|
|
202
|
+
* capability phrase the main agent might match against user input
|
|
203
|
+
* (e.g., "outbound prospect emails", "content calendar drafting").
|
|
204
|
+
* Free-form strings, comma-joined when assembled. Optional.
|
|
205
|
+
*/
|
|
206
|
+
routingHints?: string[];
|
|
207
|
+
/**
|
|
208
|
+
* Short label describing the role (e.g., "SDR", "CMO"). Used in the
|
|
209
|
+
* routing description when present.
|
|
210
|
+
*/
|
|
211
|
+
role?: string;
|
|
212
|
+
/**
|
|
213
|
+
* SDK reasoning effort tier when this profile runs as a subagent.
|
|
214
|
+
* Defaults to 'medium' if unset. Low = Haiku-style cheap fanout,
|
|
215
|
+
* High = deep reasoning, Max = max effort.
|
|
216
|
+
*/
|
|
217
|
+
effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
|
199
218
|
}
|
|
200
219
|
export type AgentStatus = 'active' | 'paused' | 'error' | 'terminated';
|
|
201
220
|
export interface HeartbeatReportedTopic {
|
|
@@ -299,7 +318,12 @@ export interface CronJobDefinition {
|
|
|
299
318
|
maxTurns?: number;
|
|
300
319
|
model?: string;
|
|
301
320
|
workDir?: string;
|
|
321
|
+
/** Display/intent hint — 'unleashed' jobs are typically long autonomous
|
|
322
|
+
* tasks. The canonical SDK path runs every job through runAgentCron
|
|
323
|
+
* identically; this field affects only UI badges + budget heuristics. */
|
|
302
324
|
mode?: 'standard' | 'unleashed';
|
|
325
|
+
/** Wall-clock cap in hours. Defaults to 1h. Triggers an AbortSignal
|
|
326
|
+
* on the runAgentCron call when exceeded. */
|
|
303
327
|
maxHours?: number;
|
|
304
328
|
maxRetries?: number;
|
|
305
329
|
after?: string;
|
package/package.json
CHANGED
package/dist/events/bus.d.ts
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Clementine TypeScript — Event Bus.
|
|
3
|
-
*
|
|
4
|
-
* Typed pub/sub system for decoupling gateway lifecycle events from consumers.
|
|
5
|
-
* Plugins, logging, metrics, and UI can subscribe without modifying core code.
|
|
6
|
-
*
|
|
7
|
-
* Events are fire-and-forget (async handlers don't block the emitter).
|
|
8
|
-
* "before" events return a boolean — false cancels the operation.
|
|
9
|
-
*/
|
|
10
|
-
export type EventHandler<T = unknown> = (payload: T) => void | Promise<void>;
|
|
11
|
-
export type BeforeHandler<T = unknown> = (payload: T) => boolean | Promise<boolean>;
|
|
12
|
-
declare class EventBus {
|
|
13
|
-
private listeners;
|
|
14
|
-
private beforeHandlers;
|
|
15
|
-
/**
|
|
16
|
-
* Subscribe to an event. Handler is called asynchronously (fire-and-forget).
|
|
17
|
-
* Returns an unsubscribe function.
|
|
18
|
-
*/
|
|
19
|
-
on<T = unknown>(event: string, handler: EventHandler<T>): () => void;
|
|
20
|
-
/** Subscribe to an event, but only fire once. */
|
|
21
|
-
once<T = unknown>(event: string, handler: EventHandler<T>): () => void;
|
|
22
|
-
/**
|
|
23
|
-
* Register a "before" handler that can cancel an operation.
|
|
24
|
-
* If any before handler returns false, the operation is cancelled.
|
|
25
|
-
*/
|
|
26
|
-
before<T = unknown>(event: string, handler: BeforeHandler<T>): () => void;
|
|
27
|
-
/**
|
|
28
|
-
* Emit an event asynchronously. Handlers run in parallel, errors are logged but don't propagate.
|
|
29
|
-
*/
|
|
30
|
-
emit<T = unknown>(event: string, payload: T): void;
|
|
31
|
-
/**
|
|
32
|
-
* Run "before" handlers sequentially. Returns true if all pass, false if any cancels.
|
|
33
|
-
*/
|
|
34
|
-
emitBefore<T = unknown>(event: string, payload: T): Promise<boolean>;
|
|
35
|
-
/** Remove all listeners for an event, or all events if no event specified. */
|
|
36
|
-
clear(event?: string): void;
|
|
37
|
-
/** Get count of listeners for an event (useful for debugging). */
|
|
38
|
-
listenerCount(event: string): number;
|
|
39
|
-
}
|
|
40
|
-
/** Singleton event bus — shared across the entire process. */
|
|
41
|
-
export declare const events: EventBus;
|
|
42
|
-
export {};
|
|
43
|
-
//# sourceMappingURL=bus.d.ts.map
|
package/dist/events/bus.js
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Clementine TypeScript — Event Bus.
|
|
3
|
-
*
|
|
4
|
-
* Typed pub/sub system for decoupling gateway lifecycle events from consumers.
|
|
5
|
-
* Plugins, logging, metrics, and UI can subscribe without modifying core code.
|
|
6
|
-
*
|
|
7
|
-
* Events are fire-and-forget (async handlers don't block the emitter).
|
|
8
|
-
* "before" events return a boolean — false cancels the operation.
|
|
9
|
-
*/
|
|
10
|
-
import pino from 'pino';
|
|
11
|
-
const logger = pino({ name: 'clementine.events' });
|
|
12
|
-
class EventBus {
|
|
13
|
-
listeners = new Map();
|
|
14
|
-
beforeHandlers = new Map();
|
|
15
|
-
/**
|
|
16
|
-
* Subscribe to an event. Handler is called asynchronously (fire-and-forget).
|
|
17
|
-
* Returns an unsubscribe function.
|
|
18
|
-
*/
|
|
19
|
-
on(event, handler) {
|
|
20
|
-
const subs = this.listeners.get(event) ?? [];
|
|
21
|
-
const sub = { handler: handler, once: false };
|
|
22
|
-
subs.push(sub);
|
|
23
|
-
this.listeners.set(event, subs);
|
|
24
|
-
return () => {
|
|
25
|
-
const list = this.listeners.get(event);
|
|
26
|
-
if (list) {
|
|
27
|
-
const idx = list.indexOf(sub);
|
|
28
|
-
if (idx !== -1)
|
|
29
|
-
list.splice(idx, 1);
|
|
30
|
-
}
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
/** Subscribe to an event, but only fire once. */
|
|
34
|
-
once(event, handler) {
|
|
35
|
-
const subs = this.listeners.get(event) ?? [];
|
|
36
|
-
const sub = { handler: handler, once: true };
|
|
37
|
-
subs.push(sub);
|
|
38
|
-
this.listeners.set(event, subs);
|
|
39
|
-
return () => {
|
|
40
|
-
const list = this.listeners.get(event);
|
|
41
|
-
if (list) {
|
|
42
|
-
const idx = list.indexOf(sub);
|
|
43
|
-
if (idx !== -1)
|
|
44
|
-
list.splice(idx, 1);
|
|
45
|
-
}
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* Register a "before" handler that can cancel an operation.
|
|
50
|
-
* If any before handler returns false, the operation is cancelled.
|
|
51
|
-
*/
|
|
52
|
-
before(event, handler) {
|
|
53
|
-
const handlers = this.beforeHandlers.get(event) ?? [];
|
|
54
|
-
const entry = { handler: handler, once: false };
|
|
55
|
-
handlers.push(entry);
|
|
56
|
-
this.beforeHandlers.set(event, handlers);
|
|
57
|
-
return () => {
|
|
58
|
-
const list = this.beforeHandlers.get(event);
|
|
59
|
-
if (list) {
|
|
60
|
-
const idx = list.findIndex(e => e === entry);
|
|
61
|
-
if (idx !== -1)
|
|
62
|
-
list.splice(idx, 1);
|
|
63
|
-
}
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* Emit an event asynchronously. Handlers run in parallel, errors are logged but don't propagate.
|
|
68
|
-
*/
|
|
69
|
-
emit(event, payload) {
|
|
70
|
-
const subs = this.listeners.get(event);
|
|
71
|
-
if (!subs || subs.length === 0)
|
|
72
|
-
return;
|
|
73
|
-
// Snapshot handlers and remove once-listeners
|
|
74
|
-
const handlers = [...subs];
|
|
75
|
-
for (let i = subs.length - 1; i >= 0; i--) {
|
|
76
|
-
if (subs[i].once)
|
|
77
|
-
subs.splice(i, 1);
|
|
78
|
-
}
|
|
79
|
-
for (const sub of handlers) {
|
|
80
|
-
try {
|
|
81
|
-
const result = sub.handler(payload);
|
|
82
|
-
if (result instanceof Promise) {
|
|
83
|
-
result.catch(err => logger.warn({ err, event }, 'Event handler error'));
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
catch (err) {
|
|
87
|
-
logger.warn({ err, event }, 'Event handler error (sync)');
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
/**
|
|
92
|
-
* Run "before" handlers sequentially. Returns true if all pass, false if any cancels.
|
|
93
|
-
*/
|
|
94
|
-
async emitBefore(event, payload) {
|
|
95
|
-
const handlers = this.beforeHandlers.get(event);
|
|
96
|
-
if (!handlers || handlers.length === 0)
|
|
97
|
-
return true;
|
|
98
|
-
for (const entry of [...handlers]) {
|
|
99
|
-
try {
|
|
100
|
-
const result = entry.handler(payload);
|
|
101
|
-
const allowed = result instanceof Promise ? await result : result;
|
|
102
|
-
if (!allowed) {
|
|
103
|
-
logger.info({ event }, 'Operation cancelled by before handler');
|
|
104
|
-
return false;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
catch (err) {
|
|
108
|
-
logger.warn({ err, event }, 'Before handler error — allowing operation');
|
|
109
|
-
}
|
|
110
|
-
if (entry.once) {
|
|
111
|
-
const idx = handlers.indexOf(entry);
|
|
112
|
-
if (idx !== -1)
|
|
113
|
-
handlers.splice(idx, 1);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return true;
|
|
117
|
-
}
|
|
118
|
-
/** Remove all listeners for an event, or all events if no event specified. */
|
|
119
|
-
clear(event) {
|
|
120
|
-
if (event) {
|
|
121
|
-
this.listeners.delete(event);
|
|
122
|
-
this.beforeHandlers.delete(event);
|
|
123
|
-
}
|
|
124
|
-
else {
|
|
125
|
-
this.listeners.clear();
|
|
126
|
-
this.beforeHandlers.clear();
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
/** Get count of listeners for an event (useful for debugging). */
|
|
130
|
-
listenerCount(event) {
|
|
131
|
-
return (this.listeners.get(event)?.length ?? 0) + (this.beforeHandlers.get(event)?.length ?? 0);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
/** Singleton event bus — shared across the entire process. */
|
|
135
|
-
export const events = new EventBus();
|
|
136
|
-
//# sourceMappingURL=bus.js.map
|