Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions api/server/services/ToolService.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ const {
isFatalAgentInitializationError,
codeExecutionAuthHeaders,
createAttachedWorkspaceBashTool,
createRepositoryInstructionSource,
createRepositoryInstructionLoader,
resolveAttachedWorkspaceCommandTimeoutMax,
createGitIdentityProgrammaticBashTool,
resolveCodeExecutionContext,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
}),
};
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1859,6 +1876,7 @@ async function loadAgentTools({

if (preparedActionSnapshot == null) {
return {
repositoryInstructionSource,
toolRegistry,
requestScopedConnections: getMCPRequestContext(req, res),
userMCPAuthMap,
Expand All @@ -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,
Expand Down Expand Up @@ -2012,6 +2031,7 @@ async function loadAgentTools({
toolRegistry,
requestScopedConnections: getMCPRequestContext(req, res),
toolContextMap,
repositoryInstructionSource,
dynamicToolContextMap,
userMCPAuthMap,
toolDefinitions,
Expand Down
1 change: 1 addition & 0 deletions client/src/common/agents-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions client/src/components/Chat/Input/CodeWorkspaceMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ function EnvironmentWorkspaces({
isSelected: (workspaceId: string) => boolean;
onSelect: (selection: CodeWorkspaceSelection) => void;
}) {
const localize = useLocalize();
return (
<div>
<Ariakit.MenuHeading render={<div />} className={headingClasses}>
Expand Down Expand Up @@ -123,6 +124,18 @@ function EnvironmentWorkspaces({
{descriptor.name && (
<p className="truncate text-xs text-text-secondary">{descriptor.id}</p>
)}
{descriptor.instructions !== undefined && (
<p className="truncate text-xs text-text-secondary">
{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(', ')}
</p>
)}
{(descriptor.environment?.repo || descriptor.environment?.ref) && (
<p className="truncate text-xs text-text-secondary">
{[descriptor.environment.repo, descriptor.environment.ref]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<CodeWorkspaceMenu setConversation={jest.fn()} workspace={state} disabled={false} />,
);
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'],
Expand Down
3 changes: 3 additions & 0 deletions client/src/components/SidePanel/Agents/AgentPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export function composeAgentUpdatePayload(
stateful_code_sessions,
stateful_code_environment,
code_environment_id,
repositoryInstructions,
code_workspace_id,
git_identity,
recursion_limit,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -667,6 +669,7 @@ export default function AgentPanel() {
create.mutate({
...basePayload,
git_identity: basePayload.git_identity ?? undefined,
repositoryInstructions: basePayload.repositoryInstructions,
model,
tools,
provider,
Expand Down
1 change: 1 addition & 0 deletions client/src/components/SidePanel/Agents/AgentSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
31 changes: 31 additions & 0 deletions client/src/components/SidePanel/Agents/Code/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,37 @@ export default function CodeSettings() {
)}
{showGitIdentity && (
<div className="space-y-2 border-t border-border-light pt-3">
<label
htmlFor="repository-instructions"
className="text-xs font-medium text-text-secondary"
>
{localize('com_ui_repository_instructions')}
</label>
<Select
value={watch('repositoryInstructions') ?? 'prefer'}
onValueChange={(value) => {
if (value === 'prefer' || value === 'defer' || value === 'off')
setValue('repositoryInstructions', value, { shouldDirty: true });
}}
>
<SelectTrigger id="repository-instructions">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="prefer">
{localize('com_ui_repository_instructions_prefer')}
</SelectItem>
<SelectItem value="defer">
{localize('com_ui_repository_instructions_defer')}
</SelectItem>
<SelectItem value="off">
{localize('com_ui_repository_instructions_off')}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-text-tertiary">
{localize('com_ui_repository_instructions_description')}
</p>
<div className="text-xs font-medium text-text-secondary">
{localize('com_ui_agent_git_identity')}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,29 @@ function IdentityForm({
<output data-testid="identity">{JSON.stringify(methods.watch('git_identity'))}</output>
<output data-testid="workspace-default">{methods.watch('code_workspace_id')}</output>
<output data-testid="machine-default">{methods.watch('code_environment_id')}</output>
<output data-testid="repository-mode">{methods.watch('repositoryInstructions')}</output>
</FormProvider>
);
}

test('repository instruction mode defaults to prefer and retains an explicit off choice', async () => {
HTMLElement.prototype.scrollIntoView = jest.fn();
render(<IdentityForm />);
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(<IdentityForm />);
Expand Down
7 changes: 7 additions & 0 deletions client/src/locales/en/translation.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
77 changes: 77 additions & 0 deletions packages/api/src/agents/__tests__/initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/agents/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
};
}
Expand Down
23 changes: 23 additions & 0 deletions packages/api/src/agents/initialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<TEndpointOption>;
Expand Down Expand Up @@ -1964,6 +1966,7 @@ export async function initializeAgent(
tools: structuredTools,
primedCodeFiles,
codeExecutionContext: loadedCodeExecutionContext,
repositoryInstructionSource,
} = loadToolsResult ?? {
tools: [],
toolContextMap: {},
Expand All @@ -1979,6 +1982,7 @@ export async function initializeAgent(
oauthActionToolNames: undefined,
primedCodeFiles: undefined,
codeExecutionContext: undefined,
repositoryInstructionSource: undefined,
};
const trustedCodeExecutionContext = loadedCodeExecutionContext ?? codeExecutionContext;
const attachedWorkspaceOperations =
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading