diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 3afc2ba13ef..a26096816c9 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -49,6 +49,8 @@ const { isFatalAgentInitializationError, codeExecutionAuthHeaders, createAttachedWorkspaceBashTool, + createRepositoryInstructionSource, + createRepositoryInstructionLoader, resolveAttachedWorkspaceCommandTimeoutMax, createGitIdentityProgrammaticBashTool, resolveCodeExecutionContext, @@ -105,6 +107,7 @@ const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/p const { primeFiles: primeSearchFiles } = require('~/app/clients/tools/util/fileSearch'); const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process'); const { manifestToolMap, toolkits } = require('~/app/clients/tools/manifest'); +const repositoryInstructionLoader = createRepositoryInstructionLoader(); const { createOnSearchResults } = require('~/server/services/Tools/search'); const { reinitMCPServer } = require('~/server/services/Tools/mcp'); const { @@ -1561,6 +1564,13 @@ async function loadToolDefinitionsWrapper({ primedCodeFiles, oauthActionToolNames, codeExecutionContext: resolvedCodeExecutionContext, + repositoryInstructionSource: createRepositoryInstructionSource({ + load: repositoryInstructionLoader, + enabled: codeExecutionEnabled, + context: resolvedCodeExecutionContext, + principalId: JSON.stringify([getTenantId(), req.user.id]), + getAuthHeaders: (workerId) => getCodeApiAuthHeaders(req, workerId), + }), }; } @@ -1757,6 +1767,13 @@ async function loadAgentTools({ getAppConfig, }); + const repositoryInstructionSource = createRepositoryInstructionSource({ + load: repositoryInstructionLoader, + enabled: codeExecutionEnabled, + context: codeExecutionContext, + principalId: JSON.stringify([getTenantId(), req.user.id]), + getAuthHeaders: (workerId) => getCodeApiAuthHeaders(req, workerId), + }); const { loadedTools, toolContextMap, dynamicToolContextMap, primedCodeFiles } = await loadTools({ agent, signal, @@ -1859,6 +1876,7 @@ async function loadAgentTools({ if (preparedActionSnapshot == null) { return { + repositoryInstructionSource, toolRegistry, requestScopedConnections: getMCPRequestContext(req, res), userMCPAuthMap, @@ -1880,6 +1898,7 @@ async function loadAgentTools({ logger.warn(`No tools found for ${_agentTools.length} specified tool call(s)`); } return { + repositoryInstructionSource, toolRegistry, requestScopedConnections: getMCPRequestContext(req, res), userMCPAuthMap, @@ -2012,6 +2031,7 @@ async function loadAgentTools({ toolRegistry, requestScopedConnections: getMCPRequestContext(req, res), toolContextMap, + repositoryInstructionSource, dynamicToolContextMap, userMCPAuthMap, toolDefinitions, diff --git a/client/src/common/agents-types.ts b/client/src/common/agents-types.ts index 5cbaa759410..72834a7585d 100644 --- a/client/src/common/agents-types.ts +++ b/client/src/common/agents-types.ts @@ -53,6 +53,7 @@ export type AgentForm = { /** Operator-configured managed or attached execution environment. */ code_environment_id?: string | null; code_workspace_id?: string; + repositoryInstructions?: 'prefer' | 'defer' | 'off'; /** Git authorship applied to sandboxed commands for this agent. */ git_identity?: Agent['git_identity']; provider?: AgentProvider | OptionWithIcon; diff --git a/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx b/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx index 783f1f00a2e..be03a3da891 100644 --- a/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx +++ b/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx @@ -95,6 +95,7 @@ function EnvironmentWorkspaces({ isSelected: (workspaceId: string) => boolean; onSelect: (selection: CodeWorkspaceSelection) => void; }) { + const localize = useLocalize(); return (
} className={headingClasses}> @@ -123,6 +124,18 @@ function EnvironmentWorkspaces({ {descriptor.name && (

{descriptor.id}

)} + {descriptor.instructions !== undefined && ( +

+ {descriptor.instructions.length === 0 + ? localize('com_ui_repository_instructions_none') + : descriptor.instructions + .map( + (file) => + `${file.path} · ${(file.bytes / 1024).toFixed(1)} KB${file.truncated ? ` · ${localize('com_ui_repository_instructions_truncated')}` : ''}`, + ) + .join(', ')} +

+ )} {(descriptor.environment?.repo || descriptor.environment?.ref) && (

{[descriptor.environment.repo, descriptor.environment.ref] diff --git a/client/src/components/Chat/Input/__tests__/CodeWorkspaceMenu.spec.tsx b/client/src/components/Chat/Input/__tests__/CodeWorkspaceMenu.spec.tsx index 40934c0e304..79a8a27f58c 100644 --- a/client/src/components/Chat/Input/__tests__/CodeWorkspaceMenu.spec.tsx +++ b/client/src/components/Chat/Input/__tests__/CodeWorkspaceMenu.spec.tsx @@ -88,6 +88,19 @@ function renderMenu(ui: React.ReactElement) { } describe('CodeWorkspaceMenu', () => { + test('shows the instruction file and truncation reported by the worker', async () => { + const state = workspace(); + state.environments[0].workspaces[0].instructions = [ + { path: 'AGENTS.md', bytes: 32768, sha256: 'a'.repeat(64), truncated: true }, + ]; + renderMenu( + , + ); + await userEvent.click(screen.getByTestId('code-workspace')); + expect( + await screen.findByText('AGENTS.md · 32.0 KB · com_ui_repository_instructions_truncated'), + ).toBeInTheDocument(); + }); test.each([ ['example/app', 'example/app · dev'], [undefined, 'dev'], diff --git a/client/src/components/SidePanel/Agents/AgentPanel.tsx b/client/src/components/SidePanel/Agents/AgentPanel.tsx index 1161266aa5e..4ee102939bc 100644 --- a/client/src/components/SidePanel/Agents/AgentPanel.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx @@ -91,6 +91,7 @@ export function composeAgentUpdatePayload( stateful_code_sessions, stateful_code_environment, code_environment_id, + repositoryInstructions, code_workspace_id, git_identity, recursion_limit, @@ -162,6 +163,7 @@ export function composeAgentUpdatePayload( stateful_code_sessions: normalizedStatefulCodeSessions, stateful_code_environment: normalizedStatefulCodeEnvironment, code_environment_id: agent_id ? code_environment_id : (code_environment_id ?? undefined), + repositoryInstructions, code_workspace_id, git_identity: normalizedGitIdentity, recursion_limit, @@ -667,6 +669,7 @@ export default function AgentPanel() { create.mutate({ ...basePayload, git_identity: basePayload.git_identity ?? undefined, + repositoryInstructions: basePayload.repositoryInstructions, model, tools, provider, diff --git a/client/src/components/SidePanel/Agents/AgentSelect.tsx b/client/src/components/SidePanel/Agents/AgentSelect.tsx index 7efe63d7056..e112c121173 100644 --- a/client/src/components/SidePanel/Agents/AgentSelect.tsx +++ b/client/src/components/SidePanel/Agents/AgentSelect.tsx @@ -101,6 +101,7 @@ function AgentSelect({ avatar_action: null, stateful_code_environment: fullAgent.stateful_code_environment ?? 'user', code_environment_id: fullAgent.code_environment_id, + repositoryInstructions: fullAgent.repositoryInstructions, code_workspace_id: fullAgent.code_workspace_id, git_identity: fullAgent.git_identity, }; diff --git a/client/src/components/SidePanel/Agents/Code/Settings.tsx b/client/src/components/SidePanel/Agents/Code/Settings.tsx index 291edf8043b..57adc4b1399 100644 --- a/client/src/components/SidePanel/Agents/Code/Settings.tsx +++ b/client/src/components/SidePanel/Agents/Code/Settings.tsx @@ -308,6 +308,37 @@ export default function CodeSettings() { )} {showGitIdentity && (

+ + +

+ {localize('com_ui_repository_instructions_description')} +

{localize('com_ui_agent_git_identity')}
diff --git a/client/src/components/SidePanel/Agents/__tests__/CodeSettings.spec.tsx b/client/src/components/SidePanel/Agents/__tests__/CodeSettings.spec.tsx index c8cfaaffbaa..81ef613fa9c 100644 --- a/client/src/components/SidePanel/Agents/__tests__/CodeSettings.spec.tsx +++ b/client/src/components/SidePanel/Agents/__tests__/CodeSettings.spec.tsx @@ -71,10 +71,29 @@ function IdentityForm({ {JSON.stringify(methods.watch('git_identity'))} {methods.watch('code_workspace_id')} {methods.watch('code_environment_id')} + {methods.watch('repositoryInstructions')} ); } +test('repository instruction mode defaults to prefer and retains an explicit off choice', async () => { + HTMLElement.prototype.scrollIntoView = jest.fn(); + render(); + expect( + screen.getByRole('combobox', { name: 'com_ui_repository_instructions' }), + ).toHaveTextContent('com_ui_repository_instructions_prefer'); + fireEvent.click(screen.getByRole('combobox', { name: 'com_ui_repository_instructions' })); + fireEvent.click( + await screen.findByRole('option', { name: 'com_ui_repository_instructions_off' }), + ); + expect(screen.getByTestId('repository-mode')).toHaveTextContent('off'); + fireEvent.click(screen.getByText('Toggle Dialog')); + fireEvent.click(screen.getByText('Toggle Dialog')); + expect( + screen.getByRole('combobox', { name: 'com_ui_repository_instructions' }), + ).toHaveTextContent('com_ui_repository_instructions_off'); +}); + test('saves a workspace default bound to the selected machine and permits clearing it', async () => { HTMLElement.prototype.scrollIntoView = jest.fn(); render(); diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 446501702ba..b3b9abb8061 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1,4 +1,11 @@ { + "com_ui_repository_instructions": "Repository instructions", + "com_ui_repository_instructions_prefer": "Prefer repository conventions", + "com_ui_repository_instructions_defer": "Prefer agent conventions", + "com_ui_repository_instructions_off": "Off", + "com_ui_repository_instructions_description": "Load AGENTS.md, or CLAUDE.md when absent, from the selected workspace when its owner enables discovery. Tool permissions remain unchanged.", + "com_ui_repository_instructions_none": "No repository instructions", + "com_ui_repository_instructions_truncated": "Truncated", "com_ui_code_workspace_default": "Default workspace for new chats", "com_ui_code_workspace_last_used": "Last used workspace", "com_ui_code_workspace_default_description": "New chats use this workspace. Without a default, reuse your last available choice for this agent and machine in this browser. Existing chats keep their workspace.", diff --git a/packages/api/src/agents/__tests__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index a6792435b80..70129505c2b 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -33,6 +33,8 @@ jest.mock('@librechat/agents', () => ({ })); import { Providers } from '@librechat/agents'; +import { createHash } from 'node:crypto'; +import { createRepositoryInstructionLoader } from '../../code/instructions'; import { Tools, Constants, @@ -287,6 +289,81 @@ describe('initializeAgent — execution context', () => { jest.clearAllMocks(); }); + it.each(['prefer', 'defer', 'off'] as const)( + 'uses saved repository instruction mode %s in definitions-only initialization', + async (mode) => { + const { agent, req, res, loadTools, db } = createMocks(); + agent.instructions = 'Agent conventions'; + agent.repositoryInstructions = mode; + const content = 'Repository conventions'; + const authHeaders = jest.fn(async () => ({})); + const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'AGENTS.md', + content, + startLine: 1, + endLine: 1, + truncated: false, + }), + ), + ); + loadTools.mockResolvedValue({ + toolDefinitions: [], + repositoryInstructionSource: { + load: createRepositoryInstructionLoader(), + enabled: true, + principalId: `test-${mode}`, + context: { + environmentType: 'attached', + baseUrl: 'https://code.example/v1', + codeWorkspace: { + workspaceId: 'primary', + operations: ['read_file'], + instructions: [ + { + path: 'AGENTS.md', + bytes: Buffer.byteLength(content), + sha256: createHash('sha256').update(content).digest('hex'), + truncated: false, + }, + ], + }, + }, + authHeaders, + }, + }); + const result = await initializeAgent( + { + req, + res, + agent, + loadTools, + endpointOption: { endpoint: EModelEndpoint.agents }, + allowedProviders: new Set([agent.provider]), + isInitialAgent: true, + }, + db, + ); + if (mode === 'off') { + expect(result.instructions).toBe('Agent conventions'); + expect(authHeaders).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + } else { + expect(result.instructions).toContain('Repository conventions'); + expect(result.instructions).toMatch(/^Agent conventions\n\nRepository-provided/); + expect(result.instructions).toContain( + mode === 'defer' ? 'unless they conflict' : 'prefer these instructions', + ); + } + expect(result.additional_instructions ?? '').not.toContain('Repository conventions'); + fetchSpy.mockRestore(); + }, + ); + it('carries request-resolved Azure identity to the run without changing persisted agent fields', async () => { const { agent, req, res, loadTools, db } = createMocks({ provider: EModelEndpoint.azureOpenAI, diff --git a/packages/api/src/agents/execution.ts b/packages/api/src/agents/execution.ts index 65e01681766..5e55e9ff3b0 100644 --- a/packages/api/src/agents/execution.ts +++ b/packages/api/src/agents/execution.ts @@ -41,6 +41,7 @@ export interface CodeExecutionContext { /** Live, server-validated directory selection. Never derive session reuse from this field. */ codeWorkspace?: CodeWorkspaceSelection & { operations: CodeWorkspaceOperation[]; + instructions?: CodeWorkspaceDescriptor['instructions']; environment?: CodeWorkspaceDescriptor['environment']; }; } diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 7f9cc7391ac..94f1a15bd3e 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -57,6 +57,7 @@ import type { import type { LCAvailableTools, RequestScopedMCPConnectionStore } from '../mcp/types'; import type { ContentTraversalLimitError } from '../protection/adapters/nested'; import type { SkillContentInput } from '../protection/adapters/submissions'; +import type { RepositoryInstructionSource } from '../code/instructions'; import type { TextContentFragment } from '../protection/types'; import type { CheckAccessParams } from '../middleware/access'; import type { MCPToolAlias } from '~/tools/classification'; @@ -861,6 +862,7 @@ export interface InitializeAgentParams { primedCodeFiles?: import('@librechat/agents').CodeEnvFile[]; /** Live workspace binding resolved by the execution-side loader. */ codeExecutionContext?: CodeExecutionContext; + repositoryInstructionSource?: RepositoryInstructionSource; } | null>; /** Endpoint option (contains model_parameters and endpoint info) */ endpointOption?: Partial; @@ -1964,6 +1966,7 @@ export async function initializeAgent( tools: structuredTools, primedCodeFiles, codeExecutionContext: loadedCodeExecutionContext, + repositoryInstructionSource, } = loadToolsResult ?? { tools: [], toolContextMap: {}, @@ -1979,6 +1982,7 @@ export async function initializeAgent( oauthActionToolNames: undefined, primedCodeFiles: undefined, codeExecutionContext: undefined, + repositoryInstructionSource: undefined, }; const trustedCodeExecutionContext = loadedCodeExecutionContext ?? codeExecutionContext; const attachedWorkspaceOperations = @@ -2261,6 +2265,25 @@ export async function initializeAgent( } } + const repositoryInstructionBlock = repositoryInstructionSource + ? await repositoryInstructionSource.load({ + ...repositoryInstructionSource, + mode: agent.repositoryInstructions, + signal: params.signal, + timeoutMs: appConfig?.endpoints?.agents?.repositoryInstructions?.timeoutMs, + assertContent: (content) => + assertModelBoundContent({ + filters: appConfig?.filters, + agents: [{ instructions: content }], + }), + }) + : undefined; + if (repositoryInstructionBlock) { + agent.instructions = [agent.instructions, repositoryInstructionBlock] + .filter(Boolean) + .join('\n\n'); + } + if (typeof agent.artifacts === 'string' && agent.artifacts !== '') { const artifactsPromptResult = generateArtifactsPrompt({ endpoint: agent.provider, diff --git a/packages/api/src/agents/management.spec.ts b/packages/api/src/agents/management.spec.ts index a1073a763bd..a2fadc80599 100644 --- a/packages/api/src/agents/management.spec.ts +++ b/packages/api/src/agents/management.spec.ts @@ -39,6 +39,20 @@ const persistedAgent = { }; describe('Agent Management contract', () => { + it('preserves repository instruction mode through API updates and response projection', () => { + for (const mode of ['prefer', 'defer', 'off'] as const) { + expect(agentManagementUpdateSchema.parse({ repositoryInstructions: mode })).toEqual({ + repositoryInstructions: mode, + }); + expect( + projectAgentManagementResponse({ ...persistedAgent, repositoryInstructions: mode }) + .repositoryInstructions, + ).toBe(mode); + } + expect( + agentManagementUpdateSchema.safeParse({ repositoryInstructions: 'allow-all' }).success, + ).toBe(false); + }); describe('inputs', () => { it('keeps create and update fields aligned with the browser Agent validators', () => { expect( diff --git a/packages/api/src/agents/management.ts b/packages/api/src/agents/management.ts index 3c297b9741a..f45e9bc6d17 100644 --- a/packages/api/src/agents/management.ts +++ b/packages/api/src/agents/management.ts @@ -280,6 +280,7 @@ export function projectAgentManagementResponse( stateful_code_environment: source.stateful_code_environment, code_environment_id: source.code_environment_id, code_workspace_id: source.code_workspace_id, + repositoryInstructions: source.repositoryInstructions, git_identity: source.git_identity, artifacts: source.artifacts, recursion_limit: source.recursion_limit, diff --git a/packages/api/src/agents/validation.ts b/packages/api/src/agents/validation.ts index fb52df7bc8a..49ada6e2121 100644 --- a/packages/api/src/agents/validation.ts +++ b/packages/api/src/agents/validation.ts @@ -508,6 +508,7 @@ export const agentBaseSchema: z.ZodObject< stateful_code_environment: z.ZodOptional>; code_environment_id: z.ZodOptional; code_workspace_id: z.ZodOptional; + repositoryInstructions: z.ZodOptional>; git_identity: typeof agentGitIdentitySchema; artifacts: z.ZodOptional; recursion_limit: z.ZodOptional; @@ -585,6 +586,7 @@ export const agentBaseSchema: z.ZodObject< stateful_code_environment: z.enum(['user', 'agent-user', 'conversation']).optional(), code_environment_id: agentCodeEnvironmentIdSchema.optional(), code_workspace_id: agentCodeWorkspaceIdSchema.optional(), + repositoryInstructions: z.enum(['prefer', 'defer', 'off']).optional(), git_identity: agentGitIdentitySchema, artifacts: z.string().optional(), recursion_limit: z.number().optional(), @@ -686,6 +688,7 @@ export const agentCreateSchema: z.ZodObject< code_environment_id: z.ZodOptional; git_identity: typeof agentGitIdentitySchema; code_workspace_id: z.ZodOptional; + repositoryInstructions: z.ZodOptional>; artifacts: z.ZodOptional; recursion_limit: z.ZodOptional; conversation_starters: z.ZodOptional>; @@ -821,6 +824,7 @@ export const agentUpdateSchema: z.ZodObject< stateful_code_environment: z.ZodOptional>; code_environment_id: z.ZodOptional>; code_workspace_id: z.ZodOptional; + repositoryInstructions: z.ZodOptional>; git_identity: typeof agentGitIdentityUpdateSchema; artifacts: z.ZodOptional; recursion_limit: z.ZodOptional; @@ -907,6 +911,7 @@ export const agentUpdateSchema: z.ZodObject< avatar: z.union([agentAvatarSchema, z.null()]).optional(), code_environment_id: agentCodeEnvironmentIdSchema.nullable().optional(), code_workspace_id: agentCodeWorkspaceIdSchema.optional(), + repositoryInstructions: z.enum(['prefer', 'defer', 'off']).optional(), git_identity: agentGitIdentityUpdateSchema, provider: z.string().optional(), model: z.string().nullable().optional(), diff --git a/packages/api/src/code/bridge.ts b/packages/api/src/code/bridge.ts index 6b5dd940d21..63db1a238b5 100644 --- a/packages/api/src/code/bridge.ts +++ b/packages/api/src/code/bridge.ts @@ -4,6 +4,7 @@ import { CODE_WORKSPACE_MAX_COUNT, CODE_WORKSPACE_OPERATIONS, isCodeWorkspaceEnvironment, + isRepositoryInstructionDescriptor, } from 'librechat-data-provider'; import type { CodeWorkspaceDescriptor, CodeWorkspaceOperation } from 'librechat-data-provider'; @@ -185,11 +186,20 @@ function validWorkspaceCapabilities(value: unknown): value is { const workspace = value as Record; if ( Object.keys(workspace).some( - (key) => key !== 'id' && key !== 'name' && key !== 'operations' && key !== 'environment', + (key) => + key !== 'id' && + key !== 'name' && + key !== 'operations' && + key !== 'environment' && + key !== 'instructions', ) || typeof workspace.id !== 'string' || !CODE_WORKSPACE_ID_PATTERN.test(workspace.id) || ids.has(workspace.id) || + (workspace.instructions !== undefined && + (!Array.isArray(workspace.instructions) || + workspace.instructions.length > 1 || + !workspace.instructions.every(isRepositoryInstructionDescriptor))) || (workspace.environment !== undefined && !isCodeWorkspaceEnvironment(workspace.environment)) || (workspace.name !== undefined && (typeof workspace.name !== 'string' || diff --git a/packages/api/src/code/capabilities.ts b/packages/api/src/code/capabilities.ts index 74a7e40469b..249c958ab18 100644 --- a/packages/api/src/code/capabilities.ts +++ b/packages/api/src/code/capabilities.ts @@ -178,6 +178,7 @@ export async function resolveCodeExecutionWorkspaceContext({ codeWorkspace: { ...selection, operations: [...(workspace.operations ?? status.operations)], + ...(workspace.instructions ? { instructions: workspace.instructions } : {}), ...(workspace.environment ? { environment: workspace.environment } : {}), }, }; diff --git a/packages/api/src/code/index.ts b/packages/api/src/code/index.ts index 219208b0698..6bf116a5a1c 100644 --- a/packages/api/src/code/index.ts +++ b/packages/api/src/code/index.ts @@ -8,3 +8,4 @@ export * from './command'; export * from './capabilities'; export * from './decision'; export * from './errors'; +export * from './instructions'; diff --git a/packages/api/src/code/instructions.spec.ts b/packages/api/src/code/instructions.spec.ts new file mode 100644 index 00000000000..9a26bf9391b --- /dev/null +++ b/packages/api/src/code/instructions.spec.ts @@ -0,0 +1,138 @@ +import { createHash } from 'node:crypto'; +import type { CodeExecutionContext } from '~/agents/execution'; +import { createRepositoryInstructionLoader } from './instructions'; + +const content = 'Use the project test command.\n'; +const sha256 = createHash('sha256').update(content).digest('hex'); +const context: CodeExecutionContext = { + baseUrl: 'https://code.example/v1', + codeSessionKey: 'session', + executionProfile: 'stateful', + statefulSessions: true, + environmentType: 'attached', + environmentId: 'machine', + bridgeWorkerId: 'worker', + codeWorkspace: { + environmentId: 'machine', + workspaceId: 'primary', + operations: ['read_file'], + instructions: [ + { path: 'AGENTS.md', bytes: Buffer.byteLength(content), sha256, truncated: false }, + ], + }, +}; +const response = () => + new Response( + JSON.stringify({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'AGENTS.md', + content, + startLine: 1, + endLine: 2, + truncated: false, + }), + { status: 200 }, + ); + +describe('repository instruction loading', () => { + it('bounds optional authorization waits and preserves explicit cancellation', async () => { + jest.useFakeTimers(); + try { + const load = createRepositoryInstructionLoader(); + const args = { + enabled: true, + context, + principalId: 'test', + assertContent: jest.fn(), + authHeaders: () => new Promise>(() => {}), + }; + const pending = load(args); + await jest.advanceTimersByTimeAsync(2000); + expect(await pending).toBeUndefined(); + let settled = false; + const slower = load({ ...args, timeoutMs: 5000 }).then((result) => { + settled = true; + return result; + }); + await jest.advanceTimersByTimeAsync(2000); + expect(settled).toBe(false); + await jest.advanceTimersByTimeAsync(3000); + expect(await slower).toBeUndefined(); + expect( + await load({ + ...args, + authHeaders: async () => { + throw new Error('unavailable'); + }, + }), + ).toBeUndefined(); + const controller = new AbortController(); + const cancelled = load({ ...args, signal: controller.signal }); + controller.abort(new Error('cancelled')); + await expect(cancelled).rejects.toThrow('cancelled'); + } finally { + jest.useRealTimers(); + } + }); + + it('checks authorization and content policy on cache hits, without cross-principal reuse', async () => { + const load = createRepositoryInstructionLoader(); + const fetchImpl = jest.fn(async () => response()); + const authHeaders = jest.fn(async () => ({ Authorization: 'Bearer test' })); + const assertContent = jest.fn(); + const input = { + enabled: true, + context, + principalId: 'alice', + fetchImpl, + authHeaders, + assertContent, + }; + const first = await load(input); + expect(first).toContain('Use the project test command.'); + expect(await load(input)).toBe(first); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(authHeaders).toHaveBeenCalledTimes(2); + expect(assertContent).toHaveBeenCalledTimes(2); + await load({ ...input, principalId: 'bob' }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(await load({ ...input, mode: 'off' })).toBeUndefined(); + expect(await load({ ...input, enabled: false })).toBeUndefined(); + expect(await load({ ...input, mode: 'defer' })).toContain('unless they conflict'); + }); + + it('omits changed, missing and unauthorized instruction snapshots', async () => { + const load = createRepositoryInstructionLoader(); + const input = { + enabled: true, + context, + principalId: 'alice', + authHeaders: async () => ({}), + assertContent: jest.fn(), + }; + expect( + await load({ ...input, fetchImpl: async () => new Response('{}', { status: 404 }) }), + ).toBeUndefined(); + expect( + await load({ + ...input, + context: { + ...context, + codeWorkspace: { + ...context.codeWorkspace!, + instructions: [{ ...context.codeWorkspace!.instructions![0], sha256: 'a'.repeat(64) }], + }, + }, + fetchImpl: async () => response(), + }), + ).toBeUndefined(); + expect( + await load({ + ...input, + context: { ...context, codeWorkspace: { ...context.codeWorkspace!, operations: [] } }, + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/api/src/code/instructions.ts b/packages/api/src/code/instructions.ts new file mode 100644 index 00000000000..e34027eaf62 --- /dev/null +++ b/packages/api/src/code/instructions.ts @@ -0,0 +1,142 @@ +import { createHash } from 'node:crypto'; +import type { CodeExecutionContext } from '~/agents/execution'; +import type { CodeBridgeFetch } from './bridge'; +import { codeExecutionAuthHeaders } from '~/agents/execution'; +import { executeWorkspaceTool } from './workspace'; + +export interface RepositoryInstructionSource { + enabled: boolean; + context: CodeExecutionContext; + principalId: string; + authHeaders: () => Promise>; + load: ReturnType; +} + +/** Transport adapters provide authority; initialization supplies the saved agent preference. */ +export function createRepositoryInstructionSource({ + getAuthHeaders, + ...source +}: Omit & { + getAuthHeaders: (workerId?: string) => Promise>; +}): RepositoryInstructionSource { + return { + ...source, + authHeaders: () => codeExecutionAuthHeaders(getAuthHeaders, source.context), + }; +} + +/** Bound header acquisition too, even when an upstream credential provider hangs. */ +async function abortable(promise: Promise, signal: AbortSignal): Promise { + let abort: () => void = () => {}; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + abort = () => reject(signal.reason); + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) abort(); + }), + ]); + } finally { + signal.removeEventListener('abort', abort); + } +} + +/** Bounded process-local content cache. Authorization is supplied fresh for each load. */ +export function createRepositoryInstructionLoader() { + const cache = new Map(); + return async ({ + context, + mode, + enabled, + principalId, + authHeaders, + signal, + fetchImpl, + assertContent, + timeoutMs = 2000, + }: { + enabled: boolean; + context: CodeExecutionContext; + mode?: 'prefer' | 'defer' | 'off'; + principalId: string; + authHeaders: () => Promise>; + signal?: AbortSignal; + fetchImpl?: CodeBridgeFetch; + assertContent: (content: string) => void; + timeoutMs?: number; + }): Promise => { + const workspace = context.codeWorkspace; + if ( + !enabled || + mode === 'off' || + context.environmentType !== 'attached' || + !workspace?.operations.includes('read_file') + ) + return; + const descriptor = workspace.instructions?.[0]; + if (!descriptor) return; + if (signal?.aborted) throw signal.reason; + const key = JSON.stringify([ + principalId, + context.baseUrl, + context.bridgeWorkerId, + workspace.workspaceId, + descriptor.path, + descriptor.sha256, + ]); + let content = cache.get(key); + const deadline = new AbortController(); + const budget = + Number.isSafeInteger(timeoutMs) && timeoutMs >= 100 && timeoutMs <= 30_000 ? timeoutMs : 2000; + const timer = setTimeout(() => deadline.abort(), budget); + const readSignal = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal; + try { + const headers = await abortable(authHeaders(), readSignal); + if (content === undefined) { + const result = await executeWorkspaceTool({ + baseURL: context.baseUrl, + authHeaders: headers, + signal: readSignal, + fetchImpl, + request: { + protocolVersion: 1, + operation: 'read_file', + workspaceId: workspace.workspaceId, + path: descriptor.path, + instructionSha256: descriptor.sha256, + }, + }); + if ( + result.operation !== 'read_file' || + Buffer.byteLength(result.content) !== descriptor.bytes || + createHash('sha256').update(result.content).digest('hex') !== descriptor.sha256 || + result.truncated !== descriptor.truncated + ) + return; + content = result.content; + if (cache.size >= 64) cache.delete(cache.keys().next().value!); + cache.set(key, content); + } + } catch { + if (signal?.aborted) throw signal.reason; + return; + } finally { + clearTimeout(timer); + } + assertContent(content); + const preference = + mode === 'defer' + ? 'Apply these repository conventions unless they conflict with the agent instructions.' + : 'For repository conventions, prefer these instructions over conflicting agent preferences.'; + const source = JSON.stringify({ + source: descriptor.path, + sha: descriptor.sha256, + workspace: workspace.workspaceId, + project: workspace.environment?.repo, + }); + /** Quote untrusted content and escape markup delimiters without changing cached bytes. */ + const quotedContent = JSON.stringify(content).replace(/\n${quotedContent}\n${descriptor.truncated ? '\n[Repository instructions truncated at 32 KiB.]' : ''}`; + }; +} diff --git a/packages/api/src/code/workspace.ts b/packages/api/src/code/workspace.ts index 5deb991b176..c461ca71e0d 100644 --- a/packages/api/src/code/workspace.ts +++ b/packages/api/src/code/workspace.ts @@ -98,6 +98,7 @@ export interface WorkspaceReadRequest { path: string; startLine?: number; maxLines?: number; + instructionSha256?: string; } export interface WorkspaceSearchRequest { @@ -447,6 +448,14 @@ function isValidRequest(request: WorkspaceToolRequest): boolean { return false; } if (request.operation === 'read_file') { + if (request.instructionSha256 !== undefined) { + return ( + /^[a-f0-9]{64}$/.test(request.instructionSha256) && + (request.path === 'AGENTS.md' || request.path === 'CLAUDE.md') && + request.startLine === undefined && + request.maxLines === undefined + ); + } return ( isSafePath(request.path) && (request.startLine == null || @@ -518,6 +527,18 @@ function isValidResult( return false; } if (request.operation === 'read_file') { + if (request.instructionSha256 !== undefined) { + return ( + hasOnlyKeys(value, READ_RESULT_KEYS) && + value.path === request.path && + typeof value.content === 'string' && + Buffer.byteLength(value.content) <= 32768 && + value.startLine === 1 && + value.endLine === value.content.split('\n').length && + typeof value.truncated === 'boolean' && + value.nextStartLine === undefined + ); + } const startLine = request.startLine ?? 1; const maxLines = request.maxLines ?? 200; const content = typeof value.content === 'string' ? value.content : null; diff --git a/packages/data-provider/src/code/workspace.ts b/packages/data-provider/src/code/workspace.ts index 60665cb7cb8..f1d2b2d1d90 100644 --- a/packages/data-provider/src/code/workspace.ts +++ b/packages/data-provider/src/code/workspace.ts @@ -33,6 +33,7 @@ export type CodeEnvironmentMode = (typeof CODE_ENVIRONMENT_MODES)[number]; export interface CodeWorkspaceDescriptor { id: string; name?: string; + instructions?: RepositoryInstructionDescriptor[]; /** Omitted when every worker-level operation applies to this workspace. */ operations?: CodeWorkspaceOperation[]; environment?: { @@ -43,6 +44,32 @@ export interface CodeWorkspaceDescriptor { }; } +export type RepositoryInstructionMode = 'prefer' | 'defer' | 'off'; +export interface RepositoryInstructionDescriptor { + path: 'AGENTS.md' | 'CLAUDE.md'; + bytes: number; + sha256: string; + truncated: boolean; +} +export function isRepositoryInstructionDescriptor( + value: unknown, +): value is RepositoryInstructionDescriptor { + if (value == null || typeof value !== 'object') return false; + const descriptor = value as Record; + return ( + Object.keys(descriptor).every((key) => + ['path', 'bytes', 'sha256', 'truncated'].includes(key), + ) && + (descriptor.path === 'AGENTS.md' || descriptor.path === 'CLAUDE.md') && + Number.isSafeInteger(descriptor.bytes) && + Number(descriptor.bytes) >= 0 && + Number(descriptor.bytes) <= 32768 && + typeof descriptor.sha256 === 'string' && + /^[a-f0-9]{64}$/.test(descriptor.sha256) && + typeof descriptor.truncated === 'boolean' + ); +} + export function isCodeWorkspaceEnvironment( value: unknown, ): value is NonNullable { diff --git a/packages/data-provider/src/config.spec.ts b/packages/data-provider/src/config.spec.ts index d097dc6bea1..96172839204 100644 --- a/packages/data-provider/src/config.spec.ts +++ b/packages/data-provider/src/config.spec.ts @@ -24,6 +24,24 @@ const endpointsConfig: TEndpointsConfig = { Gemini: { type: EModelEndpoint.custom, userProvide: false, order: 9999 }, }; +describe('repository instruction configuration', () => { + it('defaults optional reads to two seconds and bounds operator overrides', () => { + expect(agentsEndpointSchema.parse({}).repositoryInstructions).toBeUndefined(); + expect( + agentsEndpointSchema.parse({ repositoryInstructions: {} }).repositoryInstructions, + ).toEqual({ timeoutMs: 2000 }); + expect( + agentsEndpointSchema.parse({ repositoryInstructions: { timeoutMs: 5000 } }) + .repositoryInstructions, + ).toEqual({ timeoutMs: 5000 }); + for (const timeoutMs of [0, 99, 30_001, 1.5]) { + expect( + agentsEndpointSchema.safeParse({ repositoryInstructions: { timeoutMs } }).success, + ).toBe(false); + } + }); +}); + describe('ask user retained answers', () => { it('leaves the block unconfigured by default and accepts an operator budget', () => { expect(agentsEndpointSchema.parse({}).askUserQuestion).toBeUndefined(); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index cafd0e11454..151c66ba3bf 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1218,6 +1218,12 @@ export const agentsEndpointSchema = baseEndpointSchema /* agents specific */ recursionLimit: z.number().optional(), disableBuilder: z.boolean().optional().default(false), + /** Optional workspace guidance acquisition budget, separate from command execution. */ + repositoryInstructions: z + .object({ + timeoutMs: z.number().int().min(100).max(30_000).optional().default(2000), + }) + .optional(), maxRecursionLimit: z.number().optional(), /** Max cumulative bytes a single streamed tool call's arguments may reach before the run * aborts. Defaults to 64 KiB in the agents SDK; `0` disables the guard. */ diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 527f26ba95f..a43dfab2b11 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -389,6 +389,7 @@ export const defaultAgentFormValues = { stateful_code_environment: 'user' as const, code_environment_id: undefined as string | null | undefined, code_workspace_id: undefined as string | undefined, + repositoryInstructions: undefined as 'prefer' | 'defer' | 'off' | undefined, category: 'general', support_contact: { name: '', diff --git a/packages/data-provider/src/types/agents.ts b/packages/data-provider/src/types/agents.ts index 65c39d9f8bd..2b26e8dabd9 100644 --- a/packages/data-provider/src/types/agents.ts +++ b/packages/data-provider/src/types/agents.ts @@ -1010,6 +1010,7 @@ export type Agent = { code_environment_id?: string | null; /** Default attached workspace for new chats; empty means no agent default. */ code_workspace_id?: string; + repositoryInstructions?: 'prefer' | 'defer' | 'off'; /** Non-secret Git authorship injected into this agent's sandboxed commands. */ git_identity?: AgentGitIdentity | null; artifacts?: ArtifactModes; @@ -1074,6 +1075,7 @@ export type AgentCreateParams = { | 'stateful_code_environment' | 'code_environment_id' | 'code_workspace_id' + | 'repositoryInstructions' | 'artifacts' | 'recursion_limit' | 'category' @@ -1109,6 +1111,7 @@ export type AgentUpdateParams = { | 'code_environment_id' | 'git_identity' | 'code_workspace_id' + | 'repositoryInstructions' | 'artifacts' | 'recursion_limit' | 'category' diff --git a/packages/data-schemas/src/methods/agent.ts b/packages/data-schemas/src/methods/agent.ts index e68c253a2fe..ceeaa354b0b 100644 --- a/packages/data-schemas/src/methods/agent.ts +++ b/packages/data-schemas/src/methods/agent.ts @@ -1434,6 +1434,7 @@ export function createAgentMethods( projection.stateful_code_sessions = 1; projection.code_environment_id = 1; projection.code_workspace_id = 1; + projection.repositoryInstructions = 1; projection.agent_ids = 1; projection['edges.from'] = 1; projection['edges.to'] = 1; @@ -1601,6 +1602,7 @@ export function createAgentMethods( for (const field of [ 'code_environment_id', 'code_workspace_id', + 'repositoryInstructions', 'git_identity', 'skills_scope', 'skill_authoring_enabled', diff --git a/packages/data-schemas/src/schema/agent.ts b/packages/data-schemas/src/schema/agent.ts index 2f5fdfe5394..c9c51429fe6 100644 --- a/packages/data-schemas/src/schema/agent.ts +++ b/packages/data-schemas/src/schema/agent.ts @@ -95,6 +95,7 @@ const agentSchema: Schema = new Schema( type: String, }, code_workspace_id: { type: String }, + repositoryInstructions: { type: String, enum: ['prefer', 'defer', 'off'] }, git_identity: { type: new Schema( { diff --git a/packages/data-schemas/src/schema/agentWorkspace.spec.ts b/packages/data-schemas/src/schema/agentWorkspace.spec.ts index d51c452b422..a3d1fd00b02 100644 --- a/packages/data-schemas/src/schema/agentWorkspace.spec.ts +++ b/packages/data-schemas/src/schema/agentWorkspace.spec.ts @@ -1,6 +1,14 @@ import { model, models } from 'mongoose'; import agentSchema from './agent'; +it('persists repository instruction preference and rejects unknown modes', () => { + const Agent = models.WorkspaceDefaultAgent ?? model('WorkspaceDefaultAgent', agentSchema); + const agent = new Agent({ repositoryInstructions: 'off' }); + expect(new Agent(agent.toObject()).repositoryInstructions).toBe('off'); + agent.repositoryInstructions = 'invalid'; + expect(agent.validateSync()?.errors.repositoryInstructions).toBeDefined(); +}); + it('retains the agent workspace preference and explicit reset through serialization', () => { const Agent = models.WorkspaceDefaultAgent ?? model('WorkspaceDefaultAgent', agentSchema); const agent = new Agent({ code_environment_id: 'machine-a', code_workspace_id: 'project-a' }); diff --git a/packages/data-schemas/src/types/agent.ts b/packages/data-schemas/src/types/agent.ts index 6f277f3eaf6..9563be72a73 100644 --- a/packages/data-schemas/src/types/agent.ts +++ b/packages/data-schemas/src/types/agent.ts @@ -44,6 +44,7 @@ export interface IAgent extends Omit { stateful_code_environment?: 'user' | 'agent-user' | 'conversation'; code_environment_id?: string; code_workspace_id?: string; + repositoryInstructions?: 'prefer' | 'defer' | 'off'; git_identity?: AgentGitIdentity | null; /** @deprecated Use edges instead */ agent_ids?: string[];