Skip to content

Commit 4408fdf

Browse files
authored
Add global instructions config for the Studio Code agent (#4255)
1 parent e6146d8 commit 4408fdf

27 files changed

Lines changed: 603 additions & 10 deletions

File tree

‎apps/cli/ai/eval-runner.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,21 @@
77
*/
88

99
import { mkdirSync, writeFileSync, writeSync as fsWriteSync } from 'node:fs';
10+
import { rm } from 'node:fs/promises';
1011
import os from 'node:os';
1112
import path from 'node:path';
1213
import { SessionManager } from '@earendil-works/pi-coding-agent';
14+
import {
15+
readGlobalInstructionsFile,
16+
writeGlobalInstructions,
17+
} from '@studio/common/ai/global-instructions';
1318
import { DEFAULT_MODEL, isAiModelId, type AiModelId } from '@studio/common/ai/models';
1419
import { findLastAssistant } from '@studio/common/ai/session-events';
1520
import {
1621
addConnectedWpcomSite,
1722
removeConnectedWpcomSite,
1823
} from '@studio/common/lib/connected-sites';
24+
import { getGlobalInstructionsPath } from '@studio/common/lib/well-known-paths';
1925
import { snapshotSchema } from '@studio/common/types/snapshot';
2026
import { syncSiteSchema, type SyncSite } from '@studio/common/types/sync';
2127
import { z } from 'zod';
@@ -52,6 +58,7 @@ const evalSeedSchema = z.object( {
5258
.optional(),
5359
connectedWpcomSites: z.array( syncSiteSchema ).optional(),
5460
snapshots: z.array( snapshotSchema ).optional(),
61+
globalInstructions: z.string().optional(),
5562
} );
5663
type EvalSeed = z.infer< typeof evalSeedSchema >;
5764

@@ -66,9 +73,21 @@ interface EvalRunnerInput {
6673
* Writes the requested fixtures into cli.json (local site + snapshots) and
6774
* shared.json (connected WordPress.com sites). Returns a cleanup function that
6875
* removes exactly what was added, so reruns start from a clean slate.
76+
* `globalInstructions` is the exception: it overwrites the user's real
77+
* instructions file, so cleanup restores the prior content instead.
6978
*/
7079
async function seedFixtures( seed: EvalSeed ): Promise< () => Promise< void > > {
71-
const { localSite, connectedWpcomSites = [], snapshots = [] } = seed;
80+
const { localSite, connectedWpcomSites = [], snapshots = [], globalInstructions } = seed;
81+
82+
let restoreInstructions: ( () => Promise< void > ) | null = null;
83+
if ( typeof globalInstructions === 'string' ) {
84+
const prior = await readGlobalInstructionsFile();
85+
restoreInstructions =
86+
prior === null
87+
? () => rm( getGlobalInstructionsPath(), { force: true } )
88+
: () => writeGlobalInstructions( prior );
89+
await writeGlobalInstructions( globalInstructions );
90+
}
7291

7392
if ( localSite || snapshots.length > 0 ) {
7493
try {
@@ -100,6 +119,7 @@ async function seedFixtures( seed: EvalSeed ): Promise< () => Promise< void > >
100119
}
101120

102121
return async () => {
122+
await restoreInstructions?.().catch( () => undefined );
103123
for ( const site of seededConnections ) {
104124
await removeConnectedWpcomSite( site.localSiteId, site.id ).catch( () => undefined );
105125
}

‎apps/cli/ai/runtimes/pi/index.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
type SessionManager,
2525
type ToolDefinition,
2626
} from '@earendil-works/pi-coding-agent';
27+
import { readGlobalInstructions } from '@studio/common/ai/global-instructions';
2728
import {
2829
DEFAULT_MODEL,
2930
getAiModelFamily,
@@ -293,6 +294,10 @@ async function createStudioAgentSession(
293294
const isRemoteSite = Boolean( config.activeSite?.remote && config.activeSite?.wpcomSiteId );
294295
const remoteSession = config.env.STUDIO_REMOTE_SESSION === '1';
295296
const chatArtifactsEnabled = typeof process.send === 'function';
297+
const [ userInstructions, runtime ] = await Promise.all( [
298+
readGlobalInstructions(),
299+
isRemoteSite ? undefined : resolveActiveSiteRuntime( config.activeSite ),
300+
] );
296301

297302
const systemPrompt = buildSystemPrompt(
298303
isRemoteSite
@@ -303,11 +308,13 @@ async function createStudioAgentSession(
303308
id: config.activeSite!.wpcomSiteId!,
304309
},
305310
remoteSession,
311+
userInstructions,
306312
}
307313
: {
308314
chatArtifactsEnabled,
309315
remoteSession,
310-
runtime: await resolveActiveSiteRuntime( config.activeSite ),
316+
runtime,
317+
userInstructions,
311318
}
312319
);
313320

‎apps/cli/ai/system-prompt.ts‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { GLOBAL_INSTRUCTIONS_MAX_LENGTH } from '@studio/common/ai/global-instructions';
12
import {
23
getStudioPresentationRulesPrompt,
34
getStudioWidgetPromptManifest,
@@ -23,17 +24,20 @@ export interface BuildSystemPromptOptions {
2324
// Runtime of the active local site. Playground (PHP WASM) needs extra WP-CLI
2425
// constraints that the native PHP runtime does not. Defaults to native-php.
2526
runtime?: SiteRuntime;
27+
// The user's global instructions (~/.studio/knowledge/instructions.md).
28+
userInstructions?: string;
2629
}
2730

2831
export function buildSystemPrompt( options?: BuildSystemPromptOptions ): string {
2932
const remoteSessionAddendum = options?.remoteSession ? `\n\n${ REMOTE_SESSION_GUIDANCE }` : '';
33+
const userInstructionsSection = buildUserInstructionsSection( options?.userInstructions );
3034

3135
if ( options?.remoteSite ) {
3236
return `${ buildRemoteIntro( options.remoteSite ) }
3337
3438
${ REMOTE_CONTENT_GUIDELINES }
3539
36-
${ REMOTE_DESIGN_GUIDELINES }${ remoteSessionAddendum }
40+
${ REMOTE_DESIGN_GUIDELINES }${ remoteSessionAddendum }${ userInstructionsSection }
3741
`;
3842
}
3943

@@ -43,10 +47,30 @@ ${ REMOTE_DESIGN_GUIDELINES }${ remoteSessionAddendum }
4347
runtime: options?.runtime,
4448
} ) }
4549
46-
${ LOCAL_SKILL_ROUTING }${ remoteSessionAddendum }
50+
${ LOCAL_SKILL_ROUTING }${ remoteSessionAddendum }${ userInstructionsSection }
4751
`;
4852
}
4953

54+
function buildUserInstructionsSection( userInstructions?: string ): string {
55+
if ( ! userInstructions ) {
56+
return '';
57+
}
58+
const instructions =
59+
userInstructions.length > GLOBAL_INSTRUCTIONS_MAX_LENGTH
60+
? `${ userInstructions.slice(
61+
0,
62+
GLOBAL_INSTRUCTIONS_MAX_LENGTH
63+
) }\n\n[Note: the global instructions file exceeds the size limit and was truncated here. Let the user know they should shorten it in Studio settings.]`
64+
: userInstructions;
65+
return `
66+
67+
## User's global instructions
68+
69+
The user saved these standing instructions in Studio's settings. They apply to every conversation. Follow them unless they conflict with the guidance above or ask you to skip safety, plan, or validation requirements.
70+
71+
${ instructions }`;
72+
}
73+
5074
function buildRemoteIntro( site: RemoteSiteContext ): string {
5175
return `${ AGENT_IDENTITY } You manage WordPress.com sites using the WordPress.com REST API.
5276

‎apps/cli/ai/tests/system-prompt.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,32 @@ describe( 'buildSystemPrompt', () => {
163163
expect( prompt ).not.toContain( 'Do not respond as though the user is looking at the capture' );
164164
} );
165165

166+
it( 'appends the user global instructions for local and remote sessions', () => {
167+
const variants = [ { chatArtifactsEnabled: true }, { remoteSite } ];
168+
for ( const variant of variants ) {
169+
const prompt = buildSystemPrompt( {
170+
...variant,
171+
userInstructions: 'Always answer in French.',
172+
} );
173+
expect( prompt ).toContain( "## User's global instructions" );
174+
expect( prompt ).toContain( 'Always answer in French.' );
175+
}
176+
} );
177+
178+
it( 'omits the global instructions section when none are set', () => {
179+
const prompts = [ buildSystemPrompt( {} ), buildSystemPrompt( { remoteSite } ) ];
180+
for ( const prompt of prompts ) {
181+
expect( prompt ).not.toContain( "## User's global instructions" );
182+
}
183+
} );
184+
185+
it( 'truncates oversized global instructions with a visible notice', () => {
186+
const prompt = buildSystemPrompt( { userInstructions: 'a'.repeat( 20_000 ) } );
187+
188+
expect( prompt ).toContain( 'was truncated here' );
189+
expect( prompt ).not.toContain( 'a'.repeat( 17_000 ) );
190+
} );
191+
166192
it( 'omits the terminal screenshot caveat for remote-bridge sessions', () => {
167193
// The Telegram user cannot open local file paths; delivery is covered
168194
// by the remote-session share_screenshot guidance instead.

‎apps/local/src/index.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { createWriteStream, existsSync, mkdtempSync, rm } from 'node:fs';
33
import os from 'node:os';
44
import path from 'node:path';
55
import { pipeline } from 'node:stream/promises';
6+
import {
7+
readGlobalInstructionsFile,
8+
writeGlobalInstructions,
9+
} from '@studio/common/ai/global-instructions';
610
import { isAiModelId } from '@studio/common/ai/models';
711
import { createAgentRunManager } from '@studio/common/ai/run-manager';
812
import {
@@ -423,6 +427,27 @@ export async function startLocalServer( options: LocalServerOptions ): Promise<
423427
} )
424428
);
425429

430+
// --- Agent instructions — the shared ~/.studio/knowledge/instructions.md --
431+
api.get(
432+
'/agent-instructions',
433+
asyncHandler( async ( _req: Request, res: Response ) => {
434+
res.json( { content: ( await readGlobalInstructionsFile() ) ?? '' } );
435+
} )
436+
);
437+
438+
api.post(
439+
'/agent-instructions',
440+
asyncHandler( async ( req: Request, res: Response ) => {
441+
const content = req.body?.content;
442+
if ( typeof content !== 'string' ) {
443+
res.status( 400 ).json( { error: 'content required' } );
444+
return;
445+
}
446+
await writeGlobalInstructions( content );
447+
res.status( 204 ).end();
448+
} )
449+
);
450+
426451
// --- Sites — the local machine's real Studio sites, via the CLI -----------
427452
api.get(
428453
'/sites',

‎apps/studio/src/ipc-handlers.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ export {
229229

230230
export {
231231
getColorScheme,
232+
getGlobalAgentInstructions,
232233
getInstalledAppsAndTerminals,
233234
getQuitSitesBehavior,
234235
getUserEditor,
@@ -237,6 +238,7 @@ export {
237238
getWapuuScore,
238239
previewColorScheme,
239240
saveColorScheme,
241+
saveGlobalAgentInstructions,
240242
saveQuitSitesBehavior,
241243
saveUserEditor,
242244
saveUserLocale,
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { GLOBAL_INSTRUCTIONS_MAX_LENGTH } from '@studio/common/ai/global-instructions';
2+
import { getErrorMessage } from '@studio/common/lib/error-formatting';
3+
import { TextareaControl } from '@wordpress/components';
4+
import { useI18n } from '@wordpress/react-i18n';
5+
import { useEffect, useState } from 'react';
6+
import Button from 'src/components/button';
7+
import { getIpcApi } from 'src/lib/get-ipc-api';
8+
9+
export function StudioCodeTab() {
10+
const { __ } = useI18n();
11+
const [ content, setContent ] = useState< string | null >( null );
12+
const [ savedContent, setSavedContent ] = useState( '' );
13+
const [ isSaving, setIsSaving ] = useState( false );
14+
const [ justSaved, setJustSaved ] = useState( false );
15+
const [ error, setError ] = useState< string | null >( null );
16+
17+
useEffect( () => {
18+
let cancelled = false;
19+
getIpcApi()
20+
.getGlobalAgentInstructions()
21+
.then( ( instructions ) => {
22+
if ( ! cancelled ) {
23+
setContent( instructions );
24+
setSavedContent( instructions );
25+
}
26+
} )
27+
.catch( ( err ) => {
28+
if ( ! cancelled ) {
29+
setError( getErrorMessage( err ) ?? String( err ) );
30+
}
31+
} );
32+
return () => {
33+
cancelled = true;
34+
};
35+
}, [] );
36+
37+
useEffect( () => {
38+
if ( ! justSaved ) {
39+
return;
40+
}
41+
const timer = setTimeout( () => setJustSaved( false ), 2500 );
42+
return () => clearTimeout( timer );
43+
}, [ justSaved ] );
44+
45+
const handleSave = async () => {
46+
if ( content === null ) {
47+
return;
48+
}
49+
setIsSaving( true );
50+
setError( null );
51+
try {
52+
await getIpcApi().saveGlobalAgentInstructions( content );
53+
setSavedContent( content );
54+
setJustSaved( true );
55+
} catch ( err ) {
56+
setError( getErrorMessage( err ) ?? String( err ) );
57+
} finally {
58+
setIsSaving( false );
59+
}
60+
};
61+
62+
const isDirty = content !== null && content !== savedContent;
63+
const showCounter = content !== null && content.length >= GLOBAL_INSTRUCTIONS_MAX_LENGTH * 0.8;
64+
const buttonLabel = isSaving
65+
? __( 'Saving…' )
66+
: justSaved && ! isDirty
67+
? __( 'Saved' )
68+
: __( 'Save instructions' );
69+
70+
return (
71+
<div className="flex flex-col gap-4 pb-2">
72+
{ error && <div className="text-sm text-frame-error">{ error }</div> }
73+
74+
<TextareaControl
75+
__nextHasNoMarginBottom
76+
label={ __( 'Instructions' ) }
77+
help={ __(
78+
'Global instructions for the Studio Code agent. They are included in every new conversation, across all sites.'
79+
) }
80+
rows={ 12 }
81+
maxLength={ GLOBAL_INSTRUCTIONS_MAX_LENGTH }
82+
value={ content ?? '' }
83+
onChange={ setContent }
84+
disabled={ content === null }
85+
placeholder={ __( 'e.g. Always answer in French. My sites are for restaurants.' ) }
86+
/>
87+
88+
<div className="flex items-center justify-end gap-3">
89+
{ showCounter && (
90+
<span className="text-xs text-frame-text-secondary">
91+
{ `${ content.length.toLocaleString() } / ${ GLOBAL_INSTRUCTIONS_MAX_LENGTH.toLocaleString() }` }
92+
</span>
93+
) }
94+
<Button
95+
variant="primary"
96+
onClick={ handleSave }
97+
disabled={ content === null || isSaving || ! isDirty }
98+
>
99+
{ buttonLabel }
100+
</Button>
101+
</div>
102+
</div>
103+
);
104+
}

‎apps/studio/src/modules/user-settings/components/tests/user-settings.test.tsx‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// To run tests, execute `npm run test -- src/modules/user-settings/components/tests/user-settings.test.tsx` from the root directory
2-
import { render, screen, waitFor } from '@testing-library/react';
2+
import { render, screen, waitFor, within } from '@testing-library/react';
33
import userEvent from '@testing-library/user-event';
44
import { Provider } from 'react-redux';
55
import { vi } from 'vitest';
@@ -48,6 +48,8 @@ const mockIpcApi = {
4848
saveColorScheme: vi.fn().mockResolvedValue( undefined ),
4949
getQuitSitesBehavior: vi.fn().mockResolvedValue( undefined ),
5050
saveQuitSitesBehavior: vi.fn().mockResolvedValue( undefined ),
51+
getGlobalAgentInstructions: vi.fn().mockResolvedValue( '' ),
52+
saveGlobalAgentInstructions: vi.fn().mockResolvedValue( undefined ),
5153
};
5254

5355
vi.mock( 'src/lib/get-ipc-api', () => ( {
@@ -161,12 +163,25 @@ describe( 'UserSettings', () => {
161163
expect( screen.getByText( 'Account' ) ).toHaveAttribute( 'aria-selected', 'true' );
162164
expect( screen.getByText( 'Log out' ) ).toBeInTheDocument();
163165
expect( screen.getByText( 'Preview sites' ) ).toBeInTheDocument();
164-
expect( screen.getByText( 'Studio Code' ) ).toBeInTheDocument();
166+
// Scoped to the panel: "Studio Code" also names a settings tab.
167+
expect(
168+
within( screen.getByRole( 'tabpanel' ) ).getByText( 'Studio Code' )
169+
).toBeInTheDocument();
165170
expect(
166171
screen.getByText( 'Studio Code limits are temporarily unavailable.' )
167172
).toBeInTheDocument();
168173
expect( screen.queryByText( /monthly prompts used/ ) ).not.toBeInTheDocument();
169174
} );
175+
176+
await user.click( screen.getByRole( 'tab', { name: 'Studio Code' } ) );
177+
178+
await waitFor( () => {
179+
expect( screen.getByRole( 'tab', { name: 'Studio Code' } ) ).toHaveAttribute(
180+
'aria-selected',
181+
'true'
182+
);
183+
expect( screen.getByLabelText( 'Instructions' ) ).toBeInTheDocument();
184+
} );
170185
} );
171186
} );
172187

0 commit comments

Comments
 (0)